diff --git a/CHANGES.txt b/CHANGES.txt index 0dd3cdd..4892d42 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,13 @@ +6.5.0 (Nov 29, 2023) +- Added support for Flag Sets on the SDK, which enables grouping feature flags and interacting with the group rather than individually (more details in our documentation): + - Added new variations of the get treatment methods to support evaluating flags in given flag set/s. + - TreatmentsByFlagSet and TreatmentsByFlagSets + - TreatmentsWithConfigByFlagSet and TreatmentsWithConfigByFlagSets + - Added a new optional Flag Sets Filter configuration option. This allows the SDK and Split services to only synchronize the flags in the specified flag sets, avoiding unused or unwanted flags from being synced on the SDK instance, bringing all the benefits from a reduced payload. + - Note: Only applicable when the SDK is in charge of the rollout data synchronization. When not applicable, the SDK will log a warning on init. + - Updated the following SDK manager methods to expose flag sets on flag views. + - Added `DefaultTreatment` property to the `SplitView` object returned by the `Split` and `Splits` functions of the SDK manager. + 6.4.0 (Jul 18, 2023) - Improved streaming architecture implementation to apply feature flag updates from the notification received which is now enhanced, improving efficiency and reliability of the whole update system. - Pointed to new version of go-split-commons v5.0.0. diff --git a/go.mod b/go.mod index 499d039..7ed1f92 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/splitio/go-client/v6 go 1.18 require ( - github.com/splitio/go-split-commons/v5 v5.0.0 - github.com/splitio/go-toolkit/v5 v5.3.1 + github.com/splitio/go-split-commons/v5 v5.1.0 + github.com/splitio/go-toolkit/v5 v5.3.2 ) require ( @@ -13,6 +13,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/redis/go-redis/v9 v9.0.4 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sync v0.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 917f006..ee5e61c 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,14 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/redis/go-redis/v9 v9.0.4 h1:FC82T+CHJ/Q/PdyLW++GeCO+Ol59Y4T7R4jbgjvktgc= github.com/redis/go-redis/v9 v9.0.4/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= -github.com/splitio/go-split-commons/v5 v5.0.0 h1:bGRi0cf1JP5VNSi0a4BPQEWv/DACkeSKliazhPMVDPk= -github.com/splitio/go-split-commons/v5 v5.0.0/go.mod h1:lzoVmYJaCqB8UPSxWva0BZe7fF+bRJD+eP0rNi/lL7c= -github.com/splitio/go-toolkit/v5 v5.3.1 h1:9J/byd0fRxWj5/Zg0QZOnUxKBDIAMCGr7rySYzJKdJg= -github.com/splitio/go-toolkit/v5 v5.3.1/go.mod h1:xYhUvV1gga9/1029Wbp5pjnR6Cy8nvBpjw99wAbsMko= +github.com/splitio/go-split-commons/v5 v5.1.0 h1:mki1235gjXwuxcXdv/bKVduX1Lv09uXJogds+BspqSM= +github.com/splitio/go-split-commons/v5 v5.1.0/go.mod h1:9vAZrlhKvhensyRC11hyVFdgLIBrkX9D5vdYc9qB13w= +github.com/splitio/go-toolkit/v5 v5.3.2 h1:Yy9YBcHRmK5WVZjeA/klLGEdF38xpsL1ejnC3ro8a2M= +github.com/splitio/go-toolkit/v5 v5.3.2/go.mod h1:xYhUvV1gga9/1029Wbp5pjnR6Cy8nvBpjw99wAbsMko= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/splitio/client/client.go b/splitio/client/client.go index 813c790..c6988c6 100644 --- a/splitio/client/client.go +++ b/splitio/client/client.go @@ -2,7 +2,9 @@ package client import ( "errors" + "fmt" "runtime/debug" + "strings" "time" "github.com/splitio/go-client/v6/splitio/conf" @@ -11,6 +13,7 @@ import ( "github.com/splitio/go-split-commons/v5/dtos" "github.com/splitio/go-split-commons/v5/engine/evaluator" "github.com/splitio/go-split-commons/v5/engine/evaluator/impressionlabels" + "github.com/splitio/go-split-commons/v5/flagsets" "github.com/splitio/go-split-commons/v5/provisional" "github.com/splitio/go-split-commons/v5/storage" "github.com/splitio/go-split-commons/v5/telemetry" @@ -18,10 +21,14 @@ import ( ) const ( - treatment = "Treatment" - treatments = "Treatments" - treatmentWithConfig = "TreatmentWithConfig" - treatmentsWithConfig = "TreatmentsWithConfig" + treatment = "Treatment" + treatments = "Treatments" + treatmentsByFlagSet = "TreatmentsByFlagSet" + treatmentsByFlagSets = "TreatmentsByFlahSets" + treatmentWithConfig = "TreatmentWithConfig" + treatmentsWithConfig = "TreatmentsWithConfig" + treatmentsWithConfigByFlagSet = "TreatmentsWithConfigByFlagSet" + treatmentsWithConfigByFlagSets = "TrearmentsWithConfigByFlagSets" ) // SplitClient is the entry-point of the split SDK. @@ -37,6 +44,7 @@ type SplitClient struct { initTelemetry storage.TelemetryConfigProducer evaluationTelemetry storage.TelemetryEvaluationProducer runtimeTelemetry storage.TelemetryRuntimeProducer + flagSetsFilter flagsets.FlagSetFilter } // TreatmentResult struct that includes the Treatment evaluation with the corresponding Config @@ -50,7 +58,8 @@ func (c *SplitClient) getEvaluationResult(matchingKey string, bucketingKey *stri if c.isReady() { return c.evaluator.EvaluateFeature(matchingKey, bucketingKey, featureFlag, attributes) } - c.logger.Warning(operation + ": the SDK is not ready, results may be incorrect. Make sure to wait for SDK readiness before using this method") + + c.logger.Warning(fmt.Sprintf("%s: the SDK is not ready, results may be incorrect for feature flag %s. Make sure to wait for SDK readiness before using this method", operation, featureFlag)) c.initTelemetry.RecordNonReadyUsage() return &evaluator.Result{ Treatment: evaluator.Control, @@ -64,7 +73,8 @@ func (c *SplitClient) getEvaluationsResult(matchingKey string, bucketingKey *str if c.isReady() { return c.evaluator.EvaluateFeatures(matchingKey, bucketingKey, featureFlags, attributes) } - c.logger.Warning(operation + ": the SDK is not ready, results may be incorrect. Make sure to wait for SDK readiness before using this method") + featureFlagsToPrint := strings.Join(featureFlags, ", ") + c.logger.Warning(fmt.Sprintf("%s: the SDK is not ready, results may be incorrect for feature flags %s. Make sure to wait for SDK readiness before using this method", operation, featureFlagsToPrint)) c.initTelemetry.RecordNonReadyUsage() result := evaluator.Results{ EvaluationTime: 0, @@ -207,6 +217,28 @@ func (c *SplitClient) generateControlTreatments(featureFlagNames []string, opera return treatments } +func (c *SplitClient) processResult(result evaluator.Results, operation string, bucketingKey *string, matchingKey string, attributes map[string]interface{}, metricsLabel string) (t map[string]TreatmentResult) { + var bulkImpressions []dtos.Impression + treatments := make(map[string]TreatmentResult) + for feature, evaluation := range result.Evaluations { + if !c.validator.IsSplitFound(evaluation.Label, feature, operation) { + treatments[feature] = TreatmentResult{ + Treatment: evaluator.Control, + Config: nil, + } + } else { + bulkImpressions = append(bulkImpressions, c.createImpression(feature, bucketingKey, evaluation.Label, matchingKey, evaluation.Treatment, evaluation.SplitChangeNumber)) + + treatments[feature] = TreatmentResult{ + Treatment: evaluation.Treatment, + Config: evaluation.Config, + } + } + } + c.storeData(bulkImpressions, attributes, metricsLabel, result.EvaluationTime) + return treatments +} + // doTreatmentsCall retrieves treatments of an specific array of feature flag names with configurations object if it is present for a certain key and set of attributes func (c *SplitClient) doTreatmentsCall(key interface{}, featureFlagNames []string, attributes map[string]interface{}, operation string, metricsLabel string) (t map[string]TreatmentResult) { treatments := make(map[string]TreatmentResult) @@ -241,26 +273,44 @@ func (c *SplitClient) doTreatmentsCall(key interface{}, featureFlagNames []strin return map[string]TreatmentResult{} } - var bulkImpressions []dtos.Impression evaluationsResult := c.getEvaluationsResult(matchingKey, bucketingKey, filteredFeatures, attributes, operation) - for feature, evaluation := range evaluationsResult.Evaluations { - if !c.validator.IsSplitFound(evaluation.Label, feature, operation) { - treatments[feature] = TreatmentResult{ - Treatment: evaluator.Control, - Config: nil, - } - } else { - bulkImpressions = append(bulkImpressions, c.createImpression(feature, bucketingKey, evaluation.Label, matchingKey, evaluation.Treatment, evaluation.SplitChangeNumber)) - treatments[feature] = TreatmentResult{ - Treatment: evaluation.Treatment, - Config: evaluation.Config, - } + treatments = c.processResult(evaluationsResult, operation, bucketingKey, matchingKey, attributes, metricsLabel) + + return treatments +} + +// doTreatmentsCallByFlagSets retrieves treatments of a specific array of feature flag names, that belong to flag sets, with configurations object if it is present for a certain key and set of attributes +func (c *SplitClient) doTreatmentsCallByFlagSets(key interface{}, flagSets []string, attributes map[string]interface{}, operation string, metricsLabel string) (t map[string]TreatmentResult) { + treatments := make(map[string]TreatmentResult) + + // Set up a guard deferred function to recover if the SDK starts panicking + defer func() { + if r := recover(); r != nil { + // At this point we'll only trust that the logger isn't panicking trust + // that the logger isn't panicking + c.evaluationTelemetry.RecordException(metricsLabel) + c.logger.Error( + "SDK is panicking with the following error", r, "\n", + string(debug.Stack()), "\n") + t = treatments } + }() + + if c.isDestroyed() { + return treatments } - c.storeData(bulkImpressions, attributes, metricsLabel, evaluationsResult.EvaluationTime) + matchingKey, bucketingKey, err := c.validator.ValidateTreatmentKey(key, operation) + if err != nil { + c.logger.Error(err.Error()) + return treatments + } + if c.isReady() { + evaluationsResult := c.evaluator.EvaluateFeatureByFlagSets(matchingKey, bucketingKey, flagSets, attributes) + treatments = c.processResult(evaluationsResult, operation, bucketingKey, matchingKey, attributes, metricsLabel) + } return treatments } @@ -274,11 +324,91 @@ func (c *SplitClient) Treatments(key interface{}, featureFlagNames []string, att return treatmentsResult } +func (c *SplitClient) validateSets(flagSets []string) []string { + if len(flagSets) == 0 { + c.logger.Warning("sets must be a non-empty array") + return nil + } + flagSets, errs := flagsets.SanitizeMany(flagSets) + if len(errs) != 0 { + for _, err := range errs { + if errType, ok := err.(*dtos.FlagSetValidatonError); ok { + c.logger.Warning(errType.Message) + } + } + } + flagSets = c.filterSetsAreInConfig(flagSets) + if len(flagSets) == 0 { + return nil + } + return flagSets +} + +// Treatments evaluate multiple feature flag names belonging to a flag set for a single user and a set of attributes at once +func (c *SplitClient) TreatmentsByFlagSet(key interface{}, flagSet string, attributes map[string]interface{}) map[string]string { + treatmentsResult := map[string]string{} + sets := c.validateSets([]string{flagSet}) + if sets == nil { + return treatmentsResult + } + result := c.doTreatmentsCallByFlagSets(key, sets, attributes, treatmentsByFlagSet, telemetry.TreatmentsByFlagSet) + for feature, treatmentResult := range result { + treatmentsResult[feature] = treatmentResult.Treatment + } + return treatmentsResult +} + +// Treatments evaluate multiple feature flag names belonging to flag sets for a single user and a set of attributes at once +func (c *SplitClient) TreatmentsByFlagSets(key interface{}, flagSets []string, attributes map[string]interface{}) map[string]string { + treatmentsResult := map[string]string{} + flagSets = c.validateSets(flagSets) + if flagSets == nil { + return treatmentsResult + } + result := c.doTreatmentsCallByFlagSets(key, flagSets, attributes, treatmentsByFlagSets, telemetry.TreatmentsByFlagSets) + for feature, treatmentResult := range result { + treatmentsResult[feature] = treatmentResult.Treatment + } + return treatmentsResult +} + +func (c *SplitClient) filterSetsAreInConfig(flagSets []string) []string { + toReturn := []string{} + for _, flagSet := range flagSets { + if !c.flagSetsFilter.IsPresent(flagSet) { + c.logger.Warning(fmt.Sprintf("you passed %s which is not part of the configured FlagSetsFilter, ignoring Flag Set.", flagSet)) + continue + } + toReturn = append(toReturn, flagSet) + } + return toReturn +} + // TreatmentsWithConfig evaluates multiple feature flag names for a single user and set of attributes at once and returns configurations func (c *SplitClient) TreatmentsWithConfig(key interface{}, featureFlagNames []string, attributes map[string]interface{}) map[string]TreatmentResult { return c.doTreatmentsCall(key, featureFlagNames, attributes, treatmentsWithConfig, telemetry.TreatmentsWithConfig) } +// TreatmentsWithConfigByFlagSet evaluates multiple feature flag names belonging to a flag set for a single user and set of attributes at once and returns configurations +func (c *SplitClient) TreatmentsWithConfigByFlagSet(key interface{}, flagSet string, attributes map[string]interface{}) map[string]TreatmentResult { + treatmentsResult := make(map[string]TreatmentResult) + sets := c.validateSets([]string{flagSet}) + if sets == nil { + return treatmentsResult + } + return c.doTreatmentsCallByFlagSets(key, sets, attributes, treatmentsWithConfigByFlagSet, telemetry.TreatmentsByFlagSets) +} + +// TreatmentsWithConfigByFlagSet evaluates multiple feature flag names belonging to a flag sets for a single user and set of attributes at once and returns configurations +func (c *SplitClient) TreatmentsWithConfigByFlagSets(key interface{}, flagSets []string, attributes map[string]interface{}) map[string]TreatmentResult { + treatmentsResult := make(map[string]TreatmentResult) + flagSets = c.validateSets(flagSets) + if flagSets == nil { + return treatmentsResult + } + return c.doTreatmentsCallByFlagSets(key, flagSets, attributes, treatmentsWithConfigByFlagSets, telemetry.TreatmentsByFlagSets) +} + // isDestroyed returns true if the client has been destroyed func (c *SplitClient) isDestroyed() bool { return c.factory.IsDestroyed() diff --git a/splitio/client/client_test.go b/splitio/client/client_test.go index e6bff95..46c9231 100644 --- a/splitio/client/client_test.go +++ b/splitio/client/client_test.go @@ -44,6 +44,11 @@ import ( type mockEvaluator struct{} +// EvaluateFeatureByFlagSets implements evaluator.Interface. +func (*mockEvaluator) EvaluateFeatureByFlagSets(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { + panic("unimplemented") +} + func (e *mockEvaluator) EvaluateFeature( key string, bucketingKey *string, @@ -151,6 +156,32 @@ func getFactory() SplitFactory { } } +func getFactoryByFlagSets() SplitFactory { + telemetryStorage, _ := inmemory.NewTelemetryStorage() + cfg := conf.Default() + cfg.LabelsEnabled = true + cfg.Advanced.FlagSetFilter = []string{"set1", "set2"} + logger := logging.NewLogger(nil) + + impressionObserver, _ := strategy.NewImpressionObserver(500) + impressionsCounter := strategy.NewImpressionsCounter() + impressionsStrategy := strategy.NewOptimizedImpl(impressionObserver, impressionsCounter, telemetryStorage, false) + impressionManager := provisional.NewImpressionManager(impressionsStrategy) + + return SplitFactory{ + cfg: cfg, + storages: sdkStorages{ + impressions: mutexqueue.NewMQImpressionsStorage(cfg.Advanced.ImpressionsQueueSize, make(chan string, 1), logger, telemetryStorage), + events: mocks.MockEventStorage{}, + initTelemetry: telemetryStorage, + runtimeTelemetry: telemetryStorage, + evaluationTelemetry: telemetryStorage, + }, + impressionManager: impressionManager, + logger: logger, + } +} + func expectedTreatment(treatment string, expectedTreatment string, t *testing.T) { if treatment != expectedTreatment { t.Error("Expected: " + expectedTreatment + " actual: " + treatment) @@ -197,6 +228,150 @@ func TestClientGetTreatment(t *testing.T) { } } +func TestClientGetTreatmentByFlagSet(t *testing.T) { + factory := getFactoryByFlagSets() + client := factory.Client() + client.evaluator = evaluatorMock.MockEvaluator{ + EvaluateFeatureByFlagSetsCall: func(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { + results := evaluator.Results{ + Evaluations: make(map[string]evaluator.Result), + EvaluationTime: 0, + } + for _, flagSet := range flagSets { + switch flagSet { + case "set1": + results.Evaluations["feature"] = evaluator.Result{ + EvaluationTime: 0, + Label: "aLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentA", + } + default: + t.Error("Should be set1 or set2") + } + } + return results + }, + } + factory.status.Store(sdkStatusReady) + + res := client.TreatmentsByFlagSet("user1", "set1", nil) + + expectedTreatment(res["feature"], "TreatmentA", t) +} + +func TestClientGetTreatmentByFlagSets(t *testing.T) { + factory := getFactory() + client := factory.Client() + client.evaluator = evaluatorMock.MockEvaluator{ + EvaluateFeatureByFlagSetsCall: func(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { + results := evaluator.Results{ + Evaluations: make(map[string]evaluator.Result), + EvaluationTime: 0, + } + for _, flagSet := range flagSets { + switch flagSet { + case "set1": + results.Evaluations["feature"] = evaluator.Result{ + EvaluationTime: 0, + Label: "aLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentA", + } + case "set2": + results.Evaluations["feature2"] = evaluator.Result{ + EvaluationTime: 0, + Label: "bLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentB", + } + default: + t.Error("Should be set1 or set2") + } + } + return results + }, + } + factory.status.Store(sdkStatusReady) + + res := client.TreatmentsByFlagSets("user1", []string{"set1", "set2"}, nil) + + expectedTreatment(res["feature"], "TreatmentA", t) + expectedTreatment(res["feature2"], "TreatmentB", t) +} + +func TestClientGetTreatmentWithConfigByFlagSet(t *testing.T) { + factory := getFactory() + client := factory.Client() + client.evaluator = evaluatorMock.MockEvaluator{ + EvaluateFeatureByFlagSetsCall: func(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { + results := evaluator.Results{ + Evaluations: make(map[string]evaluator.Result), + EvaluationTime: 0, + } + for _, flagSet := range flagSets { + switch flagSet { + case "set1": + results.Evaluations["feature"] = evaluator.Result{ + EvaluationTime: 0, + Label: "aLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentA", + } + default: + t.Error("Should be set1 or set2") + } + } + return results + }, + } + factory.status.Store(sdkStatusReady) + + res := client.TreatmentsWithConfigByFlagSet("user1", "set1", nil) + + expectedTreatment(res["feature"].Treatment, "TreatmentA", t) +} + +func TestClientGetTreatmentWithConfigByFlagSets(t *testing.T) { + factory := getFactory() + client := factory.Client() + client.evaluator = evaluatorMock.MockEvaluator{ + EvaluateFeatureByFlagSetsCall: func(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { + results := evaluator.Results{ + Evaluations: make(map[string]evaluator.Result), + EvaluationTime: 0, + } + for _, flagSet := range flagSets { + switch flagSet { + case "set1": + results.Evaluations["feature"] = evaluator.Result{ + EvaluationTime: 0, + Label: "aLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentA", + } + case "set2": + results.Evaluations["feature2"] = evaluator.Result{ + EvaluationTime: 0, + Label: "bLabel", + SplitChangeNumber: 123, + Treatment: "TreatmentB", + } + default: + t.Error("Should be set1 or set2") + } + } + return results + }, + } + factory.status.Store(sdkStatusReady) + + res := client.TreatmentsWithConfigByFlagSets("user1", []string{"set1", "set2"}, nil) + + expectedTreatment(res["feature"].Treatment, "TreatmentA", t) + expectedTreatment(res["feature2"].Treatment, "TreatmentB", t) +} + func TestTreatments(t *testing.T) { factory := getFactory() client := factory.Client() diff --git a/splitio/client/factory.go b/splitio/client/factory.go index aca1750..c387446 100644 --- a/splitio/client/factory.go +++ b/splitio/client/factory.go @@ -18,6 +18,7 @@ import ( "github.com/splitio/go-split-commons/v5/dtos" "github.com/splitio/go-split-commons/v5/engine" "github.com/splitio/go-split-commons/v5/engine/evaluator" + "github.com/splitio/go-split-commons/v5/flagsets" "github.com/splitio/go-split-commons/v5/healthcheck/application" "github.com/splitio/go-split-commons/v5/provisional" "github.com/splitio/go-split-commons/v5/provisional/strategy" @@ -132,25 +133,25 @@ func (f *SplitFactory) IsReady() bool { } // initializates tasks for in-memory mode -func (f *SplitFactory) initializationManager(readyChannel chan int) { +func (f *SplitFactory) initializationManager(readyChannel chan int, flagSetsInvalid int64) { go f.syncManager.Start() msg := <-readyChannel switch msg { case synchronizer.Ready: // Broadcast ready status for SDK - f.broadcastReadiness(sdkStatusReady, make([]string, 0)) + f.broadcastReadiness(sdkStatusReady, make([]string, 0), flagSetsInvalid) default: - f.broadcastReadiness(sdkInitializationFailed, make([]string, 0)) + f.broadcastReadiness(sdkInitializationFailed, make([]string, 0), flagSetsInvalid) } } func (f *SplitFactory) initializationRedis() { go f.syncManager.Start() - f.broadcastReadiness(sdkStatusReady, make([]string, 0)) + f.broadcastReadiness(sdkStatusReady, make([]string, 0), 0) } // recordInitTelemetry In charge of recording init stats from redis and memory -func (f *SplitFactory) recordInitTelemetry(tags []string, currentFactories map[string]int64) { +func (f *SplitFactory) recordInitTelemetry(tags []string, currentFactories map[string]int64, flagSetsInvalid int64) { f.logger.Debug("Sending init telemetry") f.telemetrySync.SynchronizeConfig( telemetry.InitConfig{ @@ -172,6 +173,8 @@ func (f *SplitFactory) recordInitTelemetry(tags []string, currentFactories map[s TaskPeriods: config.TaskPeriods(f.cfg.TaskPeriods), ImpressionsMode: f.cfg.ImpressionsMode, ListenerEnabled: f.cfg.Advanced.ImpressionListener != nil, + FlagSetsTotal: int64(len(f.cfg.Advanced.FlagSetFilter)), + FlagSetsInvalid: flagSetsInvalid, }, time.Now().UTC().Sub(f.startTime).Milliseconds(), currentFactories, @@ -180,7 +183,7 @@ func (f *SplitFactory) recordInitTelemetry(tags []string, currentFactories map[s } // broadcastReadiness broadcasts message to all the subscriptors -func (f *SplitFactory) broadcastReadiness(status int, tags []string) { +func (f *SplitFactory) broadcastReadiness(status int, tags []string, flagSetsInvalid int64) { f.mutex.Lock() defer f.mutex.Unlock() if f.status.Load() == sdkStatusInitializing && status == sdkStatusReady { @@ -190,7 +193,7 @@ func (f *SplitFactory) broadcastReadiness(status int, tags []string) { subscriptor <- status } // At this point the SDK is ready for sending telemetry - go f.recordInitTelemetry(tags, getFactories()) + go f.recordInitTelemetry(tags, getFactories(), flagSetsInvalid) } // subscribes listener @@ -283,13 +286,17 @@ func setupInMemoryFactory( logger logging.LoggerInterface, metadata dtos.Metadata, ) (*SplitFactory, error) { - advanced := conf.NormalizeSDKConf(cfg.Advanced) + advanced, warnings := conf.NormalizeSDKConf(cfg.Advanced) + printWarnings(logger, warnings) + flagSetsInvalid := int64(len(cfg.Advanced.FlagSetFilter) - len(advanced.FlagSetsFilter)) if strings.TrimSpace(cfg.SplitSyncProxyURL) != "" { advanced.StreamingEnabled = false } inMememoryFullQueue := make(chan string, 2) // Size 2: So that it's able to accept one event from each resource simultaneously. - splitsStorage := mutexmap.NewMMSplitStorage() + + flagSetFilter := flagsets.NewFlagSetFilter(advanced.FlagSetsFilter) + splitsStorage := mutexmap.NewMMSplitStorage(flagSetFilter) segmentsStorage := mutexmap.NewMMSegmentStorage() telemetryStorage, err := inmemory.NewTelemetryStorage() impressionsStorage := mutexqueue.NewMQImpressionsStorage(cfg.Advanced.ImpressionsQueueSize, inMememoryFullQueue, logger, telemetryStorage) @@ -302,7 +309,7 @@ func setupInMemoryFactory( splitAPI := api.NewSplitAPI(apikey, advanced, logger, metadata) workers := synchronizer.Workers{ - SplitUpdater: split.NewSplitUpdater(splitsStorage, splitAPI.SplitFetcher, logger, telemetryStorage, dummyHC), + SplitUpdater: split.NewSplitUpdater(splitsStorage, splitAPI.SplitFetcher, logger, telemetryStorage, dummyHC, flagSetFilter), SegmentUpdater: segment.NewSegmentUpdater(splitsStorage, segmentsStorage, splitAPI.SegmentFetcher, logger, telemetryStorage, dummyHC), EventRecorder: event.NewEventRecorderSingle(eventsStorage, splitAPI.EventRecorder, logger, metadata, telemetryStorage), TelemetryRecorder: telemetry.NewTelemetrySynchronizer(telemetryStorage, splitAPI.TelemetryRecorder, splitsStorage, segmentsStorage, logger, metadata, telemetryStorage), @@ -376,7 +383,7 @@ func setupInMemoryFactory( splitFactory.status.Store(sdkStatusInitializing) setFactory(splitFactory.apikey, splitFactory.logger) - go splitFactory.initializationManager(readyChannel) + go splitFactory.initializationManager(readyChannel, flagSetsInvalid) return &splitFactory, nil } @@ -396,8 +403,15 @@ func setupRedisFactory(apikey string, cfg *conf.SplitSdkConfig, logger logging.L } inMememoryFullQueue := make(chan string, 2) // Size 2: So that it's able to accept one event from each resource simultaneously. impressionStorage := redis.NewImpressionStorage(redisClient, metadata, logger) + + if len(cfg.Advanced.FlagSetFilter) != 0 { + cfg.Advanced.FlagSetFilter = []string{} + logger.Warning("FlagSets filter is not applicable for Consumer modes where the SDK does not keep rollout data in sync. FlagSet filter was discarded") + } + flagSetFilter := flagsets.NewFlagSetFilter([]string{}) + storages := sdkStorages{ - splits: redis.NewSplitStorage(redisClient, logger), + splits: redis.NewSplitStorage(redisClient, logger, flagSetFilter), segments: redis.NewSegmentStorage(redisClient, logger), impressionsConsumer: impressionStorage, impressions: impressionStorage, @@ -458,7 +472,11 @@ func setupLocalhostFactory( logger logging.LoggerInterface, metadata dtos.Metadata, ) (*SplitFactory, error) { - splitStorage := mutexmap.NewMMSplitStorage() + flagSets, errs := flagsets.SanitizeMany(cfg.Advanced.FlagSetFilter) + flagSetsInvalid := int64(len(cfg.Advanced.FlagSetFilter) - len(flagSets)) + printWarnings(logger, errs) + flagSetFilter := flagsets.NewFlagSetFilter(flagSets) + splitStorage := mutexmap.NewMMSplitStorage(flagSetFilter) segmentStorage := mutexmap.NewMMSegmentStorage() telemetryStorage, err := inmemory.NewTelemetryStorage() if err != nil { @@ -530,7 +548,7 @@ func setupLocalhostFactory( setFactory(splitFactory.apikey, splitFactory.logger) // Call fetching tasks as goroutine - go splitFactory.initializationManager(readyChannel) + go splitFactory.initializationManager(readyChannel, flagSetsInvalid) return splitFactory, nil } @@ -640,3 +658,13 @@ func buildImpressionManager( return provisional.NewImpressionManager(impressionsStrategy), nil } } + +func printWarnings(logger logging.LoggerInterface, errs []error) { + if len(errs) != 0 { + for _, err := range errs { + if errType, ok := err.(dtos.FlagSetValidatonError); ok { + logger.Warning(errType.Message) + } + } + } +} diff --git a/splitio/client/factory_test.go b/splitio/client/factory_test.go new file mode 100644 index 0000000..a751260 --- /dev/null +++ b/splitio/client/factory_test.go @@ -0,0 +1,35 @@ +package client + +import ( + "testing" + + "github.com/splitio/go-split-commons/v5/flagsets" +) + +func TestPrintWarnings(t *testing.T) { + + flagSets, warnings := flagsets.SanitizeMany([]string{"set1", " set2"}) + if len(flagSets) != 2 { + t.Error("flag set size should be 2") + } + printWarnings(getMockedLogger(), warnings) + if !mW.Matches("Flag Set name set2 has extra whitespace, trimming") { + t.Error("Wrong message") + } + flagSets, warnings = flagsets.SanitizeMany([]string{"set1", "Set2"}) + if len(flagSets) != 2 { + t.Error("flag set size should be 2") + } + printWarnings(getMockedLogger(), warnings) + if !mW.Matches("Flag Set name Set2 should be all lowercase - converting string to lowercase") { + t.Error("Wrong message") + } + flagSets, warnings = flagsets.SanitizeMany([]string{"set1", "@set4"}) + if len(flagSets) != 1 { + t.Error("flag set size should be 1") + } + printWarnings(getMockedLogger(), warnings) + if !mW.Matches("you passed @set4, Flag Set must adhere to the regular expressions ^[a-z0-9][_a-z0-9]{0,49}$. This means a Flag Set must start with a letter or number, be in lowercase, alphanumeric and have a max length of 50 characters. @set4 was discarded.") { + t.Error("Wrong message") + } +} diff --git a/splitio/client/input_validator_test.go b/splitio/client/input_validator_test.go index 4f41539..3a47f7d 100644 --- a/splitio/client/input_validator_test.go +++ b/splitio/client/input_validator_test.go @@ -1,17 +1,24 @@ package client import ( + "compress/gzip" + "encoding/json" "fmt" + "io/ioutil" "math" "math/rand" + "net/http" + "net/http/httptest" "strings" "sync" "testing" "time" "github.com/splitio/go-client/v6/splitio/conf" + commonsCfg "github.com/splitio/go-split-commons/v5/conf" spConf "github.com/splitio/go-split-commons/v5/conf" "github.com/splitio/go-split-commons/v5/dtos" + "github.com/splitio/go-split-commons/v5/flagsets" "github.com/splitio/go-split-commons/v5/healthcheck/application" "github.com/splitio/go-split-commons/v5/provisional" "github.com/splitio/go-split-commons/v5/provisional/strategy" @@ -20,6 +27,7 @@ import ( "github.com/splitio/go-split-commons/v5/storage/inmemory/mutexmap" "github.com/splitio/go-split-commons/v5/storage/inmemory/mutexqueue" "github.com/splitio/go-split-commons/v5/storage/mocks" + "github.com/splitio/go-split-commons/v5/storage/redis" "github.com/splitio/go-split-commons/v5/synchronizer" "github.com/splitio/go-toolkit/v5/logging" ) @@ -517,6 +525,146 @@ func TestLocalhostTrafficType(t *testing.T) { mW.Reset() } +func TestInMemoryFactoryFlagSets(t *testing.T) { + var splitsMock, _ = ioutil.ReadFile("../../testdata/splits_mock.json") + var splitMock, _ = ioutil.ReadFile("../../testdata/split_mock.json") + + postChannel := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/splitChanges": + if r.RequestURI != "/splitChanges?sets=a%2Cc%2Cd&since=-1" { + t.Error("wrong RequestURI for flag sets") + } + fmt.Fprintln(w, fmt.Sprintf(string(splitsMock), splitMock)) + return + case "/segmentChanges/___TEST___": + w.Header().Add("Content-Encoding", "gzip") + gzw := gzip.NewWriter(w) + defer gzw.Close() + fmt.Fprintln(gzw, "Hello, client") + return + case "/testImpressions/bulk": + case "/events/bulk": + for header := range r.Header { + if (header == "SplitSDKMachineIP") || (header == "SplitSDKMachineName") { + t.Error("Should not insert one of SplitSDKMachineIP, SplitSDKMachineName") + } + } + + rBody, _ := ioutil.ReadAll(r.Body) + var dataInPost []map[string]interface{} + err := json.Unmarshal(rBody, &dataInPost) + if err != nil { + t.Error(err) + return + } + + if len(dataInPost) < 1 { + t.Error("It should send data") + } + fmt.Fprintln(w, "ok") + postChannel <- "finished" + case "/segmentChanges": + case "/metrics/config": + rBody, _ := ioutil.ReadAll(r.Body) + var dataInPost dtos.Config + err := json.Unmarshal(rBody, &dataInPost) + if err != nil { + t.Error(err) + return + } + if dataInPost.FlagSetsInvalid != 4 { + t.Error("invalid flag sets should be 4") + } + if dataInPost.FlagSetsTotal != 7 { + t.Error("total flag sets should be 7") + } + default: + fmt.Fprintln(w, "ok") + return + } + })) + defer ts.Close() + cfg := conf.Default() + cfg.LabelsEnabled = true + cfg.IPAddressesEnabled = true + cfg.Advanced.EventsURL = ts.URL + cfg.Advanced.SdkURL = ts.URL + cfg.Advanced.TelemetryServiceURL = ts.URL + cfg.Advanced.AuthServiceURL = ts.URL + cfg.Advanced.ImpressionListener = &ImpressionListenerTest{} + cfg.TaskPeriods.ImpressionSync = 60 + cfg.TaskPeriods.EventsSync = 60 + cfg.Advanced.StreamingEnabled = false + cfg.Advanced.FlagSetFilter = []string{"a", "_b", "a", "a", "c", "d", "_d"} + + factory, _ := NewSplitFactory("test", cfg) + client := factory.Client() + errBlock := client.BlockUntilReady(15) + + if errBlock != nil { + t.Error("client should be ready") + } + + if !client.isReady() { + t.Error("InMemory should be ready") + } + + mW.Reset() + if mW.Length() > 0 { + t.Error("Wrong message") + } + mW.Reset() + + client.Destroy() +} + +func TestConsumerFactoryFlagSets(t *testing.T) { + logger := getMockedLogger() + sdkConf := conf.Default() + sdkConf.OperationMode = conf.RedisConsumer + sdkConf.Advanced.FlagSetFilter = []string{"a", "b"} + sdkConf.Logger = logger + + factory, _ := NewSplitFactory("something", sdkConf) + if !mW.Matches("FlagSets filter is not applicable for Consumer modes where the SDK does not keep rollout data in sync. FlagSet filter was discarded") { + t.Error("Wrong message") + } + if !factory.IsReady() { + t.Error("Factory should be ready immediately") + } + client := factory.Client() + if !client.factory.IsReady() { + t.Error("Client should be ready immediately") + } + + err := client.BlockUntilReady(1) + if err != nil { + t.Error("Error was not expected") + } + + manager := factory.Manager() + if !manager.factory.IsReady() { + t.Error("Manager should be ready immediately") + } + err = manager.BlockUntilReady(1) + if err != nil { + t.Error("Error was not expected") + } + + prefixedClient, _ := redis.NewRedisClient(&commonsCfg.RedisConfig{ + Host: "localhost", + Port: 6379, + Password: "", + Prefix: "", + }, logging.NewLogger(&logging.LoggerOptions{})) + deleteDataGenerated(prefixedClient) + + client.Destroy() +} + func TestNotReadyYet(t *testing.T) { nonReadyUsages := 0 logger := getMockedLogger() @@ -542,37 +690,39 @@ func TestNotReadyYet(t *testing.T) { initTelemetry: telemetryStorage, evaluationTelemetry: telemetryStorage, } + flagSetFilter := flagsets.NewFlagSetFilter([]string{}) maganerNotReady := SplitManager{ initTelemetry: telemetryStorage, factory: factoryNotReady, logger: logger, - splitStorage: mutexmap.NewMMSplitStorage(), + splitStorage: mutexmap.NewMMSplitStorage(flagSetFilter), } factoryNotReady.status.Store(sdkStatusInitializing) expectedMessage := "{operation}: the SDK is not ready, results may be incorrect. Make sure to wait for SDK readiness before using this method" + expectedMessage1 := "{operation}: the SDK is not ready, results may be incorrect for feature flag feature. Make sure to wait for SDK readiness before using this method" + expectedMessage2 := "{operation}: the SDK is not ready, results may be incorrect for feature flags feature, feature_2. Make sure to wait for SDK readiness before using this method" clientNotReady.Treatment("test", "feature", nil) - if !mW.Matches(strings.Replace(expectedMessage, "{operation}", "Treatment", 1)) { + if !mW.Matches(strings.Replace(expectedMessage1, "{operation}", "Treatment", 1)) { t.Error("Wrong message") } clientNotReady.Treatments("test", []string{"feature", "feature_2"}, nil) - if !mW.Matches(strings.Replace(expectedMessage, "{operation}", "Treatments", 1)) { + if !mW.Matches(strings.Replace(expectedMessage2, "{operation}", "Treatments", 1)) { t.Error("Wrong message") } clientNotReady.TreatmentWithConfig("test", "feature", nil) - if !mW.Matches(strings.Replace(expectedMessage, "{operation}", "TreatmentWithConfig", 1)) { + if !mW.Matches(strings.Replace(expectedMessage1, "{operation}", "TreatmentWithConfig", 1)) { t.Error("Wrong message") } clientNotReady.TreatmentsWithConfig("test", []string{"feature", "feature_2"}, nil) - if !mW.Matches(strings.Replace(expectedMessage, "{operation}", "TreatmentsWithConfig", 1)) { + if !mW.Matches(strings.Replace(expectedMessage2, "{operation}", "TreatmentsWithConfig", 1)) { t.Error("Wrong message") } - expected := "Track: the SDK is not ready, results may be incorrect. Make sure to wait for SDK readiness before using this method" expectedTrack(clientNotReady.Track("key", "traffic", "eventType", nil, nil), expected, t) @@ -597,7 +747,8 @@ func TestNotReadyYet(t *testing.T) { } func TestManagerWithEmptySplit(t *testing.T) { - splitStorage := mutexmap.NewMMSplitStorage() + flagSetFilter := flagsets.NewFlagSetFilter([]string{}) + splitStorage := mutexmap.NewMMSplitStorage(flagSetFilter) factory := SplitFactory{} manager := SplitManager{ splitStorage: splitStorage, diff --git a/splitio/client/manager.go b/splitio/client/manager.go index db35ab9..756340b 100644 --- a/splitio/client/manager.go +++ b/splitio/client/manager.go @@ -19,12 +19,14 @@ type SplitManager struct { // SplitView is a partial representation of a currently stored split type SplitView struct { - Name string `json:"name"` - TrafficType string `json:"trafficType"` - Killed bool `json:"killed"` - Treatments []string `json:"treatments"` - ChangeNumber int64 `json:"changeNumber"` - Configs map[string]string `json:"configs"` + Name string `json:"name"` + TrafficType string `json:"trafficType"` + Killed bool `json:"killed"` + Treatments []string `json:"treatments"` + ChangeNumber int64 `json:"changeNumber"` + Configs map[string]string `json:"configs"` + DefaultTreatment string `json:"defaultTreatment"` + Sets []string `json:"sets"` } func newSplitView(splitDto *dtos.SplitDTO) *SplitView { @@ -34,13 +36,19 @@ func newSplitView(splitDto *dtos.SplitDTO) *SplitView { treatments = append(treatments, partition.Treatment) } } + sets := []string{} + if splitDto.Sets != nil { + sets = splitDto.Sets + } return &SplitView{ - ChangeNumber: splitDto.ChangeNumber, - Killed: splitDto.Killed, - Name: splitDto.Name, - TrafficType: splitDto.TrafficTypeName, - Treatments: treatments, - Configs: splitDto.Configurations, + ChangeNumber: splitDto.ChangeNumber, + Killed: splitDto.Killed, + Name: splitDto.Name, + TrafficType: splitDto.TrafficTypeName, + Treatments: treatments, + Configs: splitDto.Configurations, + DefaultTreatment: splitDto.DefaultTreatment, + Sets: sets, } } diff --git a/splitio/client/manager_test.go b/splitio/client/manager_test.go index d95df99..9708101 100644 --- a/splitio/client/manager_test.go +++ b/splitio/client/manager_test.go @@ -4,19 +4,23 @@ import ( "testing" "github.com/splitio/go-split-commons/v5/dtos" + "github.com/splitio/go-split-commons/v5/flagsets" "github.com/splitio/go-split-commons/v5/storage/inmemory/mutexmap" "github.com/splitio/go-toolkit/v5/datastructures/set" "github.com/splitio/go-toolkit/v5/logging" ) func TestSplitManager(t *testing.T) { - splitStorage := mutexmap.NewMMSplitStorage() + flagSetFilter := flagsets.NewFlagSetFilter([]string{}) + splitStorage := mutexmap.NewMMSplitStorage(flagSetFilter) splitStorage.Update([]dtos.SplitDTO{ { - ChangeNumber: 123, - Name: "split1", - Killed: false, - TrafficTypeName: "tt1", + ChangeNumber: 123, + Name: "split1", + Killed: false, + TrafficTypeName: "tt1", + Sets: []string{"set1", "set2"}, + DefaultTreatment: "s1p1", Conditions: []dtos.ConditionDTO{ { Partitions: []dtos.PartitionDTO{ @@ -69,6 +73,14 @@ func TestSplitManager(t *testing.T) { t.Error("Incorrect treatments for split 1") } + if len(s1.Sets) != 2 { + t.Error("split1 should have 2 sets") + } + + if s1.DefaultTreatment != "s1p1" { + t.Error("the default treatment for split1 should be s1p1") + } + s2 := manager.Split("split2") if s2.Name != "split2" || !s2.Killed || s2.TrafficType != "tt2" || s2.ChangeNumber != 123 { t.Error("Split 2 stored incorrectly") @@ -77,6 +89,10 @@ func TestSplitManager(t *testing.T) { t.Error("Incorrect treatments for split 2") } + if s2.Sets == nil && len(s2.Sets) != 0 { + t.Error("split2 sets should be empty array") + } + all := manager.Splits() if len(all) != 2 { t.Error("Incorrect number of splits returned") @@ -89,7 +105,8 @@ func TestSplitManager(t *testing.T) { } func TestSplitManagerWithConfigs(t *testing.T) { - splitStorage := mutexmap.NewMMSplitStorage() + flagSetFilter := flagsets.NewFlagSetFilter([]string{}) + splitStorage := mutexmap.NewMMSplitStorage(flagSetFilter) splitStorage.Update([]dtos.SplitDTO{*valid, *killed, *noConfig}, nil, 123) logger := logging.NewLogger(nil) @@ -123,6 +140,9 @@ func TestSplitManagerWithConfigs(t *testing.T) { if s1.Configs["on"] != "{\"color\": \"blue\",\"size\": 13}" { t.Error("It should have configs") } + if s1.DefaultTreatment != "off" { + t.Error("the default treatment for valid should be off") + } s2 := manager.Split("killed") if s2.Name != "killed" || !s2.Killed || s2.TrafficType != "user" || s2.ChangeNumber != 1494593336752 { @@ -137,6 +157,9 @@ func TestSplitManagerWithConfigs(t *testing.T) { if s2.Configs["defTreatment"] != "{\"color\": \"orange\",\"size\": 15}" { t.Error("It should have configs") } + if s2.DefaultTreatment != "defTreatment" { + t.Error("the default treatment for killed should be defTreatment") + } s3 := manager.Split("noConfig") if s3.Name != "noConfig" || s3.Killed || s3.TrafficType != "user" || s3.ChangeNumber != 1494593336752 { @@ -148,6 +171,9 @@ func TestSplitManagerWithConfigs(t *testing.T) { if s3.Configs != nil { t.Error("It should not have configs") } + if s3.DefaultTreatment != "defTreatment" { + t.Error("the default treatment for killed should be defTreatment") + } all := manager.Splits() if len(all) != 3 { diff --git a/splitio/conf/sdkconf.go b/splitio/conf/sdkconf.go index e94cb76..1f3ab62 100644 --- a/splitio/conf/sdkconf.go +++ b/splitio/conf/sdkconf.go @@ -94,6 +94,7 @@ type AdvancedConfig struct { ImpressionsQueueSize int ImpressionsBulkSize int64 StreamingEnabled bool + FlagSetFilter []string } // Default returns a config struct with all the default values diff --git a/splitio/conf/util.go b/splitio/conf/util.go index 5e89f55..378d4e9 100644 --- a/splitio/conf/util.go +++ b/splitio/conf/util.go @@ -4,10 +4,11 @@ import ( "strings" "github.com/splitio/go-split-commons/v5/conf" + "github.com/splitio/go-split-commons/v5/flagsets" ) // NormalizeSDKConf compares against SDK Config to set defaults -func NormalizeSDKConf(sdkConfig AdvancedConfig) conf.AdvancedConfig { +func NormalizeSDKConf(sdkConfig AdvancedConfig) (conf.AdvancedConfig, []error) { config := conf.GetDefaultAdvancedConfig() if sdkConfig.HTTPTimeout > 0 { config.HTTPTimeout = sdkConfig.HTTPTimeout @@ -47,5 +48,7 @@ func NormalizeSDKConf(sdkConfig AdvancedConfig) conf.AdvancedConfig { } config.StreamingEnabled = sdkConfig.StreamingEnabled - return config + flagSets, errs := flagsets.SanitizeMany(sdkConfig.FlagSetFilter) + config.FlagSetsFilter = flagSets + return config, errs } diff --git a/splitio/version.go b/splitio/version.go index 87adead..65db916 100644 --- a/splitio/version.go +++ b/splitio/version.go @@ -1,4 +1,4 @@ package splitio // Version contains a string with the split sdk version -const Version = "6.4.0" +const Version = "6.5.0"