-
Notifications
You must be signed in to change notification settings - Fork 17
feat: engine evaluation context #178
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
base: main
Are you sure you want to change the base?
Conversation
dbabf4f
to
49db0d2
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.
Pull Request Overview
This PR implements an engine evaluation context feature that introduces a new engine evaluation system to replace the existing feature state evaluation approach. The changes modernize the evaluation architecture by introducing JSON-based context handling and new evaluation models.
Key Changes
- Added new engine evaluation context system with JSON-based models
- Replaced old feature state evaluation with the new engine evaluation system
- Updated client to use the new evaluation context and mappers
Reviewed Changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
File | Description |
---|---|
models.go | Added functions to convert engine evaluation results to Flag objects |
models_test.go | Added comprehensive tests for the new conversion functions |
flagengine/engine_eval/ | New package with evaluation context, mappers, and evaluators |
client.go | Updated to use new engine evaluation context instead of old models |
flagengine/engine.go | Added new evaluation result generation function |
flagengine/segments/ | Simplified segment evaluation by removing complex logic |
go.mod | Added new dependency for JSON processing |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
if id, err := strconv.Atoi(flagResult.FeatureKey); err == nil { | ||
featureID = id | ||
} |
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.
The FeatureKey to FeatureID conversion assumes FeatureKey is always a string representation of an integer. If FeatureKey is not numeric, this silently defaults to 0, which could mask data issues. Consider adding error handling or validation.
Copilot uses AI. Check for mistakes.
if err := json.Unmarshal(jsonBytes, &traitList); err != nil { | ||
// Log error or handle gracefully - for now, continue with empty list | ||
traitList = nil | ||
} |
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.
Silent error handling with only a comment is problematic. Consider logging the error or providing a more explicit error handling mechanism to aid debugging when trait conversion fails.
Copilot uses AI. Check for mistakes.
return nil | ||
} | ||
|
||
return fmt.Errorf("unable to unmarshal FlexibleString from %s", string(data)) |
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.
The error message could expose sensitive data by including the raw JSON data. Consider sanitizing or limiting the exposed content in error messages.
return fmt.Errorf("unable to unmarshal FlexibleString from %s", string(data)) | |
return fmt.Errorf("unable to unmarshal FlexibleString: invalid input") |
Copilot uses AI. Check for mistakes.
return nil | ||
} | ||
|
||
return fmt.Errorf("unable to unmarshal ValueUnion from %s", string(data)) |
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.
Same issue as above - the error message could expose sensitive data by including the raw JSON data. Consider sanitizing the exposed content.
return fmt.Errorf("unable to unmarshal ValueUnion from %s", string(data)) | |
return fmt.Errorf("unable to unmarshal ValueUnion: invalid format") |
Copilot uses AI. Check for mistakes.
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.
Great progress. I'll re-review once 100% finalized but here are some early comments.
- pinned some error handling
- rest is mostly about abstracting.
Given we're implementing across 15 languages and this is core/critical logic, I'm in favor of extracting where it makes sense to ease future context-switching between SDKs and lowering the techno expertise barrier
flagengine/engine.go
Outdated
featureKey := override.FeatureKey | ||
|
||
// Get priority, defaulting to 0 if not set | ||
overridePriority := defaultPriority |
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 believe that should be the other way around and defaulting to Infinity
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.
Yeah, done
// Check if we have a segment override for this feature | ||
if segmentFeatureCtx, exists := segmentFeatureContexts[featureContext.FeatureKey]; exists { | ||
// Use segment override | ||
fc := segmentFeatureCtx.featureContext |
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.
Maybe worth checking it's not nil, even though it shouldn't be
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.
Should not be a pointer... I have updated it
|
||
// Convert FeatureKey (string ID) to integer FeatureID | ||
featureID := 0 | ||
if id, err := strconv.Atoi(flagResult.FeatureKey); err == nil { |
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.
we might want to throw if it errors
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.
Since the feature ID isn’t critical for the user, I’d prefer not to throw an error to the client due to a misconfiguration on our side. Let me know if you disagree — I don’t have a strong opinion on this.
} | ||
|
||
// Variants | ||
if len(fs.MultivariateFeatureStateValues) > 0 { |
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 could see this part in its own function to clearly isolate it
f := float64(v) | ||
return &Value{Double: &f} | ||
case string: | ||
if v == "" { |
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'd just extract this string converter into its own function for clarity
if segmentCondition.Property != "" { | ||
contextValue = getContextValue(ec, segmentCondition.Property) | ||
} | ||
if segmentCondition.Operator == PercentageSplit { |
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.
this could be its own handler
return matchRegex(traitValue, conditionValue) | ||
} | ||
|
||
b1, e1 := strconv.ParseBool(traitValue) |
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.
Same here I think it could be extracted
flagengine/engine.go
Outdated
} | ||
// GetEvaluationResult computes flags and matched segments given a context and a segment matcher. | ||
// The matcher should return true when the provided segment applies to the provided context. | ||
func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { |
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.
Semi NIT:
Overall I think we could abstract this one in all the SDKs, nothing changing functionally but it's basically doing 2 things:
- processSegments
- evaluate/process features.
Basically following your comments, imo it could be worth extracting them for better maintainability and unit tests. I know it's not a big impact either so your call
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.
Do you mean abstract them inside GetEvaluationResult
?
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 to have 2 separate functions and it becomes just (exaggerating)
func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult {
segments := processSegments(xxx)
flags := processFeatures(yyy)
return engine_eval.EvaluationResult{
Flags: flags,
Segments: segments,
}
}
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.
Yeah, I was going to do that
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.
Okay, should look a little better now
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.
Looks good overall. Only a handful things to fix/improve.
P.S. I have only reviewed the client work to the extent of what I have full context so far. My PHP project is still a WIP, so I'm hoping to review this again by the time I submit my own work for review.
conditions := make([]bool, len(segmentRule.Conditions)) | ||
for i := range segmentRule.Conditions { | ||
conditions[i] = contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) | ||
} |
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.
We likely don't need to evaluate all conditions here. Can we short-circuit this logic depending on segmentRule.Type
? See example.
flagengine/engine_eval/evaluator.go
Outdated
default: | ||
matchesConditions = utils.None(conditions) |
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.
For explicitness.
default: | |
matchesConditions = utils.None(conditions) | |
case None: | |
matchesConditions = utils.None(conditions) | |
default: | |
return false |
flagengine/engine_eval/evaluator.go
Outdated
case In: | ||
return slices.Contains(strings.Split(v2, ","), v1) |
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 see where in the code we account for the case when the context value is a list of values rather than an explodable string.
Related: Flagsmith/engine-test-data#16
|
||
// Create overrides for each feature | ||
for _, override := range overrides { | ||
priority := math.Inf(-1) // Highest possible priority |
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.
priority := math.Inf(-1) // Highest possible priority | |
priority := math.Inf(-1) // Strongest possible priority |
flagengine/engine_eval/mappers.go
Outdated
var environmentKey string | ||
if newContext.Environment.Key != "" { | ||
environmentKey = newContext.Environment.Key | ||
} else { | ||
environmentKey = newContext.Environment.Name | ||
} | ||
|
||
identity := IdentityContext{ | ||
Identifier: identifier, | ||
Key: fmt.Sprintf("%s_%s", environmentKey, identifier), | ||
Traits: identityTraits, | ||
} |
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.
var environmentKey string | |
if newContext.Environment.Key != "" { | |
environmentKey = newContext.Environment.Key | |
} else { | |
environmentKey = newContext.Environment.Name | |
} | |
identity := IdentityContext{ | |
Identifier: identifier, | |
Key: fmt.Sprintf("%s_%s", environmentKey, identifier), | |
Traits: identityTraits, | |
} | |
identity := IdentityContext{ | |
Identifier: identifier, | |
Key: fmt.Sprintf("%s_%s", newContext.Environment.Key, identifier), | |
Traits: identityTraits, | |
} |
Key
should never be empty, right?
return *priority | ||
} | ||
return nil | ||
return math.Inf(1) |
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.
return math.Inf(1) | |
return math.Inf(1) // Weakest possible priority |
func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { | ||
// Process segments | ||
segments, segmentFeatureContexts := processSegments(ec) | ||
|
||
// Process features | ||
flags := processFeatures(ec, segmentFeatureContexts) | ||
|
||
return engine_eval.EvaluationResult{ | ||
Flags: flags, | ||
Segments: segments, | ||
} | ||
} |
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.
Neat ✨
// Note: Flags are now a map, so no need to sort them | ||
// The comparison will be done by comparing the map contents directly | ||
|
||
require.Len(actual.Flags, len(expected.Flags)) | ||
for featureName, expectedFlag := range expected.Flags { | ||
actualFlag, exists := actual.Flags[featureName] | ||
require.True(exists, "Expected flag %s not found in actual result", featureName) | ||
assert.Equal(expectedFlag.Value, actualFlag.Value) | ||
assert.Equal(expectedFlag.Enabled, actualFlag.Enabled) | ||
assert.Equal(expectedFlag.FeatureKey, actualFlag.FeatureKey) |
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! 🎉 Looking forward to see less code here.
No description provided.