-
Notifications
You must be signed in to change notification settings - Fork 3
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
FF-2678 flag evaluation details #13
Conversation
9c5b0be
to
890378a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I love a lot of the changes you made here. I do have some concerns that the structure differs from the docs, but I also don't think it's that big of a deal so long as the team has some appetite for SDK differences.
Outside of these structural differences there are a few things missing. They don't necessarily have to be in this PR, but I think they should be in the release.
-
orderPosition
is missing from each allocation. Since the allocation keys are justallocation-1234
, it's a lot easier to identify the allocation by the order (1-indexed), since they're also numbered in the UI. This is called out in the docs quite a bit, so I think it'd be good to add. -
evaluationDetails
needs to be added to theAssignmentEvent
forAssignmentLogger.log_assignment
. More often than not, I'd imagine engineers are going to log evaluation details to a service like Datadog so that they can diagnose issues encountered by a user. -
We'll want to leverage the tests from the
sdk-test-data
repo. Each of the test cases inufc/tests/*
has anevaluationDetails
key for this purpose. -
Nit: I don't think this is blocking, but I do think we should split
allocations
intomatchedAllocation
,unmatchedAllocations
, andunevaluatedAllocations
as the JS/Node SDK does. This would make the feature more consistent with docs, and it's also a better developer experience to immediately show which allocation was matched / missed at the top level of the object, rather than having them dig through the array of each allocation to find this. Perf is a non-issue since we're only talking about a handful of allocations per flag and it can be done in an O(n) time complexity. I can also disagree and commit on this if others think it's not necessary.
2639301
to
3df8cdb
Compare
4b66be4
to
e16f2e8
Compare
(rebased to resolve merge conflict) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
halfway through reviewing
eppo_core/src/ufc/error.rs
Outdated
/// Enum representing possible errors that can occur during flag evaluation. | ||
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] | ||
#[non_exhaustive] | ||
pub enum FlagEvaluationError { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These enums seem pretty different from the JavaScript ones (link). We should probably aim to keep these consistent and talk about which ones we want.
For example, the JavaScript has a specific NO_ACTIONS_SUPPLIED_FOR_BANDIT
, and also BANDIT_ERROR
if a variation was successfully assigned but an issue was encountered selecting an action.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have updated this one:
- Split it into
EvaluationError
(which are real error and are returned to the user) and an internalEvaluationFailure
(which describes all reasons why we could fail to assign variation for a flag). - Brought it closer to JS.
eppo_core/src/ufc/eval.rs
Outdated
subject_key; | ||
"error occurred while evaluating a flag: {err}", | ||
); | ||
Err(err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this make the error "fatal"?
We want all calls to be "safe" (if graceful mode--which we should support toggling--is on)
Parse errors and such can be thrown on initialization, as we encourage wrapping initialization in a try-catch
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No. There are no fatal error in Rust
Parsing error happens outside of initialization as configuration is fetched in a separate background thread.
eppo_core/src/ufc/eval.rs
Outdated
visitor.on_configuration(config); | ||
|
||
config.flags.eval_flag( | ||
visitor, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting thought using the visitor pattern here. Do you think the speedup is worth the additional code complexity vs just always computing the details and then returning only the value for the non-details assignment methods?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Detailed evaluation is currently 80%–4x slower than detail-less version. See this comment for more benchmarking details.
eppo_core/src/ufc/eval.rs
Outdated
None | ||
) -> Result<&Split, AllocationNonMatchReason> { | ||
if self.start_at.is_some_and(|t| now < t) { | ||
return Err(AllocationNonMatchReason::BeforeStartDate); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this a normal pattern in rust to return errors in the course of "normal" operation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Error<T, E>
is just a normal rust type for when one branch is returning a value (Ok(value)
) and another one fails to produce the value for any reason (Err(err)
). Unless the reason is trivial, in which case one would use Option<T>
with Some(value)
and None
. This is actually the reason why the function signature changed from Option
to Result
—because we want to know the reason
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rest of review
Also, of course, combine with today's live discussion notes (link).
// Start a poller thread to fetch configuration from the server. | ||
let poller = client.start_poller_thread()?; | ||
|
||
// Block waiting for configuration. Until this call returns, the client will return None for all |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor, but I thinking "blocking wait" is the term we're looking for
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think "blocking wait" is a noun? I'm using the verb here to describe what's going on.
/// Get the assignment value for a given feature flag and subject, along with details of why | ||
/// this value was selected. | ||
/// | ||
/// *NOTE:* It is a debug function and is slower due to the need to collect all the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it really that much slower? Our JS implementation always calls it, and we haven't heard any complaints (at least not yet).
I almost think in a different world, if we started from scratch, we'd only have this method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, see above comment
let config = self.configuration_store.get_configuration(); | ||
let assignment = | ||
config.get_assignment(flag_key, subject_key, subject_attributes, expected_type)?; | ||
let assignment = get_assignment( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we want the "untyped" assignment methods in UFC, forcing folks to declare the type they want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get_assignment
here is an internal function.
We do have a top-level Client.get_assignment
and Client.get_assignment_details
though, that I'm OK to remove. (Though they are still typed in a sense that they require user to check the type before accessing the value.)
Previously, AssignmentValue was serialized as follows: { "BOOLEAN": true } After this change, it is serialized as: { "type": "BOOLEAN", "value": true }
e2813e3
to
1490785
Compare
1490785
to
d02f430
Compare
pub bandit_evaluation_code: Option<BanditEvaluationCode>, | ||
pub flag_evaluation_code: Option<FlagEvaluationCode>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One new deviation from JS SDK: bandit_evaluation_code
and flag_evaluation_code
are exposed separately. The reasoning is that overriding flag_evaluation_code
with bandit evaluation result may hide some interesting details (i.e., why the given variation was selected)
https://linear.app/eppo/issue/FF-2678/evaluation-reasons-in-rust
Examples:
Example 1
Example 2
Example 3
Example 4
Example 5