From a7a1407cff6b43e4438f27e79430e2ea1529f7b9 Mon Sep 17 00:00:00 2001 From: Peter Csajtai Date: Mon, 9 Jan 2023 17:37:07 +0100 Subject: [PATCH] Evaluation details (#56) * Add GetEvaluationDetails() * Hooks * GetAllValueDetails() & Offline mode * Offline test * Minor consolidation * Update eval.go * Update snapshot_test.go * Comments * EvaluationDetailsMeta -> EvaluationDetailsData * Update flag.go --- v7/config_fetcher.go | 55 +++++++--- v7/config_parser.go | 4 +- v7/configcat_client.go | 61 ++++++++++- v7/configcat_client_test.go | 198 ++++++++++++++++++++++++++++++++++++ v7/configserver_test.go | 8 +- v7/eval.go | 16 +-- v7/eval_details.go | 78 ++++++++++++++ v7/flag.go | 141 +++++++++++++++++++++++-- v7/go.mod | 20 ++-- v7/go.sum | 4 + v7/logger.go | 12 ++- v7/snapshot.go | 140 ++++++++++++++++++------- v7/snapshot_test.go | 4 +- 13 files changed, 655 insertions(+), 86 deletions(-) create mode 100644 v7/eval_details.go diff --git a/v7/config_fetcher.go b/v7/config_fetcher.go index 6dca524..88a5cbe 100644 --- a/v7/config_fetcher.go +++ b/v7/config_fetcher.go @@ -31,6 +31,8 @@ type configFetcher struct { defaultUser User pollingIdentifier string overrides *FlagOverrides + hooks *Hooks + offline bool ctx context.Context ctxCancel func() @@ -61,7 +63,9 @@ func newConfigFetcher(cfg Config, logger *leveledLogger, defaultUser User) *conf cacheKey: sdkKeyToCacheKey(cfg.SDKKey), overrides: cfg.FlagOverrides, changeNotify: cfg.ChangeNotify, + hooks: cfg.Hooks, logger: logger, + offline: cfg.Offline, client: &http.Client{ Timeout: cfg.HTTPTimeout, Transport: cfg.Transport, @@ -184,6 +188,9 @@ func (f *configFetcher) fetcher(prevConfig *config, logError bool) { if f.changeNotify != nil && !config.equalContent(prevConfig) { go f.changeNotify() } + if f.hooks != nil && f.hooks.OnConfigChanged != nil && !config.equalContent(prevConfig) { + go f.hooks.OnConfigChanged() + } } // Unblock any Client.getValue call that's waiting for the first configuration to be retrieved. f.doneGetOnce.Do(func() { @@ -196,41 +203,63 @@ func (f *configFetcher) fetcher(prevConfig *config, logError bool) { func (f *configFetcher) fetchConfig(ctx context.Context, baseURL string, prevConfig *config) (_ *config, _newURL string, _err error) { if f.overrides != nil && f.overrides.Behavior == LocalOnly { // TODO could potentially refresh f.overrides if it's come from a file. - cfg, err := parseConfig(nil, "", time.Now(), f.logger, f.defaultUser, f.overrides) + cfg, err := parseConfig(nil, "", time.Now(), f.logger, f.defaultUser, f.overrides, f.hooks) if err != nil { return nil, "", err } return cfg, "", nil } + + // If we are in offline mode skip HTTP completely and fall back to cache every time. + if f.offline { + if f.cache == nil { + return nil, "", fmt.Errorf("the SDK is in offline mode and no cache is configured") + } + cfg := f.readCache(ctx, prevConfig) + if cfg == nil { + return nil, "", fmt.Errorf("the SDK is in offline mode and wasn't able to read a valid configuration from the cache") + } + return cfg, baseURL, nil + } + + // We are online, use HTTP cfg, newBaseURL, err := f.fetchHTTP(ctx, baseURL, prevConfig) if err == nil { return cfg, newBaseURL, nil } - if f.cache == nil { + f.logger.Infof("falling back to cache after config fetch error: %v", err) + cfg = f.readCache(ctx, prevConfig) + if cfg == nil { return nil, "", err } - f.logger.Infof("falling back to cache after config fetch error: %v", err) + return cfg, baseURL, nil +} + +func (f *configFetcher) readCache(ctx context.Context, prevConfig *config) (_ *config) { + if f.cache == nil { + return nil + } // Fall back to the cache configText, cacheErr := f.cache.Get(ctx, f.cacheKey) if cacheErr != nil { f.logger.Errorf("cache get failed: %v", cacheErr) - return nil, "", err + return nil } if len(configText) == 0 { f.logger.Debugf("empty config text in cache") - return nil, "", err + return nil } - cfg, cacheErr = parseConfig(configText, "", time.Time{}, f.logger, f.defaultUser, f.overrides) - if cacheErr != nil { - f.logger.Errorf("cache contained invalid config: %v", err) - return nil, "", err + cfg, parseErr := parseConfig(configText, "", time.Time{}, f.logger, f.defaultUser, f.overrides, f.hooks) + if parseErr != nil { + f.logger.Errorf("cache contained invalid config: %v", parseErr) + return nil } if prevConfig == nil || !cfg.fetchTime.Before(prevConfig.fetchTime) { f.logger.Debugf("returning cached config %v", cfg.body()) - return cfg, baseURL, nil + return cfg } // The cached config is older than the one we already had. - return nil, "", err + return nil } // fetchHTTP fetches the configuration while respecting redirects. @@ -301,7 +330,7 @@ func (f *configFetcher) fetchHTTPWithoutRedirect(ctx context.Context, baseURL st if f.sdkKey == "" { return nil, fmt.Errorf("empty SDK key in configcat configuration") } - request, err := http.NewRequest("GET", baseURL+"/configuration-files/"+f.sdkKey+"/"+configJSONName+".json", nil) + request, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/configuration-files/"+f.sdkKey+"/"+configJSONName+".json", nil) if err != nil { return nil, err } @@ -327,7 +356,7 @@ func (f *configFetcher) fetchHTTPWithoutRedirect(ctx context.Context, baseURL st if err != nil { return nil, fmt.Errorf("config fetch read failed: %v", err) } - config, err := parseConfig(body, response.Header.Get("Etag"), time.Now(), f.logger, f.defaultUser, f.overrides) + config, err := parseConfig(body, response.Header.Get("Etag"), time.Now(), f.logger, f.defaultUser, f.overrides, f.hooks) if err != nil { return nil, fmt.Errorf("config fetch returned invalid body: %v", err) } diff --git a/v7/config_parser.go b/v7/config_parser.go index 6678191..6e6b1f7 100644 --- a/v7/config_parser.go +++ b/v7/config_parser.go @@ -50,7 +50,7 @@ type config struct { // than the index into the config.values or Snapshot.values slice. type valueID = int32 -func parseConfig(jsonBody []byte, etag string, fetchTime time.Time, logger *leveledLogger, defaultUser User, overrides *FlagOverrides) (*config, error) { +func parseConfig(jsonBody []byte, etag string, fetchTime time.Time, logger *leveledLogger, defaultUser User, overrides *FlagOverrides, hooks *Hooks) (*config, error) { var root wireconfig.RootNode // Note: jsonBody can be nil when we've got overrides only. if jsonBody != nil { @@ -72,7 +72,7 @@ func parseConfig(jsonBody []byte, etag string, fetchTime time.Time, logger *leve } conf.fixup(make(map[interface{}]valueID)) conf.precalculate() - conf.defaultUserSnapshot = _newSnapshot(conf, defaultUser, logger) + conf.defaultUserSnapshot = _newSnapshot(conf, defaultUser, logger, hooks) return conf, nil } diff --git a/v7/configcat_client.go b/v7/configcat_client.go index ab607e5..c82cee9 100644 --- a/v7/configcat_client.go +++ b/v7/configcat_client.go @@ -10,6 +10,18 @@ import ( const DefaultPollInterval = 60 * time.Second +// Hooks describes the events sent by Client. +type Hooks struct { + // OnFlagEvaluated is called each time when the SDK evaluates a feature flag or setting. + OnFlagEvaluated func(details *EvaluationDetails) + + // OnError is called when an error occurs inside the ConfigCat SDK. + OnError func(err error) + + // OnConfigChanged is called, when a new config.json has downloaded. + OnConfigChanged func() +} + // Config describes configuration options for the Client. type Config struct { // SDKKey holds the key for the SDK. This parameter @@ -64,6 +76,7 @@ type Config struct { // ChangeNotify is called, if not nil, when the settings configuration // has changed. + // Deprecated: Use Hooks instead. ChangeNotify func() // DataGovernance specifies the data governance mode. @@ -85,6 +98,12 @@ type Config struct { // FlagOverrides holds the feature flag and setting overrides. FlagOverrides *FlagOverrides + + // Hooks controls the events sent by Client. + Hooks *Hooks + + // Offline indicates whether the SDK should be initialized in offline mode or not. + Offline bool } // ConfigCache is a cache API used to make custom cache implementations. @@ -153,7 +172,7 @@ func NewCustomClient(cfg Config) *Client { if cfg.PollInterval < 1 { cfg.PollInterval = DefaultPollInterval } - logger := newLeveledLogger(cfg.Logger) + logger := newLeveledLogger(cfg.Logger, cfg.Hooks) if cfg.FlagOverrides != nil { cfg.FlagOverrides.loadEntries(logger) } @@ -183,6 +202,11 @@ func (client *Client) RefreshIfOlder(ctx context.Context, age time.Duration) err return client.fetcher.refreshIfOlder(ctx, time.Now().Add(-age), true) } +// IsOffline returns true when the SDK is configured not to initiate HTTP requests, otherwise false. +func (client *Client) IsOffline() bool { + return client.cfg.Offline +} + // Close shuts down the client. After closing, it shouldn't be used. func (client *Client) Close() { client.fetcher.close() @@ -200,23 +224,55 @@ func (client *Client) GetBoolValue(key string, defaultValue bool, user User) boo return Bool(key, defaultValue).Get(client.Snapshot(user)) } +// GetBoolValueDetails returns the value and evaluation details of a boolean-typed feature flag. +// If user is non-nil, it will be used to choose the value (see the User documentation for details). +// If user is nil and Config.DefaultUser was non-nil, that will be used instead. +// +// In Lazy refresh mode, this can block indefinitely while the configuration +// is fetched. Use RefreshIfOlder explicitly if explicit control of timeouts +// is needed. +func (client *Client) GetBoolValueDetails(key string, defaultValue bool, user User) BoolEvaluationDetails { + return Bool(key, defaultValue).GetWithDetails(client.Snapshot(user)) +} + // GetIntValue is like GetBoolValue except for int-typed (whole number) feature flags. func (client *Client) GetIntValue(key string, defaultValue int, user User) int { return Int(key, defaultValue).Get(client.Snapshot(user)) } +// GetIntValueDetails is like GetBoolValueDetails except for int-typed (whole number) feature flags. +func (client *Client) GetIntValueDetails(key string, defaultValue int, user User) IntEvaluationDetails { + return Int(key, defaultValue).GetWithDetails(client.Snapshot(user)) +} + // GetFloatValue is like GetBoolValue except for float-typed (decimal number) feature flags. func (client *Client) GetFloatValue(key string, defaultValue float64, user User) float64 { return Float(key, defaultValue).Get(client.Snapshot(user)) } +// GetFloatValueDetails is like GetBoolValueDetails except for float-typed (decimal number) feature flags. +func (client *Client) GetFloatValueDetails(key string, defaultValue float64, user User) FloatEvaluationDetails { + return Float(key, defaultValue).GetWithDetails(client.Snapshot(user)) +} + // GetStringValue is like GetBoolValue except for string-typed (text) feature flags. func (client *Client) GetStringValue(key string, defaultValue string, user User) string { return String(key, defaultValue).Get(client.Snapshot(user)) } +// GetStringValueDetails is like GetBoolValueDetails except for string-typed (text) feature flags. +func (client *Client) GetStringValueDetails(key string, defaultValue string, user User) StringEvaluationDetails { + return String(key, defaultValue).GetWithDetails(client.Snapshot(user)) +} + +// GetAllValueDetails returns values along with evaluation details of all feature flags and settings. +func (client *Client) GetAllValueDetails(user User) []EvaluationDetails { + return client.Snapshot(user).GetAllValueDetails() +} + // GetVariationID returns the variation ID (analytics) that will be used for the given key // with respect to the given user, or the default value if none is found. +// Deprecated: This method is obsolete and will be removed in a future major version. Please use GetBoolValueDetails / GetIntValueDetails / GetFloatValueDetails / GetStringValueDetails instead. func (client *Client) GetVariationID(key string, defaultVariationId string, user User) string { if result := client.Snapshot(user).GetVariationID(key); result != "" { return result @@ -226,6 +282,7 @@ func (client *Client) GetVariationID(key string, defaultVariationId string, user // GetVariationIDs returns all variation IDs (analytics) in the current configuration // that apply to the given user, or Config.DefaultUser if user is nil. +// Deprecated: This method is obsolete and will be removed in a future major version. Please use GetAllValueDetails instead. func (client *Client) GetVariationIDs(user User) []string { return client.Snapshot(user).GetVariationIDs() } @@ -266,5 +323,5 @@ func (client *Client) Snapshot(user User) *Snapshot { }) } } - return newSnapshot(client.fetcher.current(), user, client.logger) + return newSnapshot(client.fetcher.current(), user, client.logger, client.cfg.Hooks) } diff --git a/v7/configcat_client_test.go b/v7/configcat_client_test.go index 0bd46c3..a76413d 100644 --- a/v7/configcat_client_test.go +++ b/v7/configcat_client_test.go @@ -539,12 +539,193 @@ func TestSnapshot_Get(t *testing.T) { c.Check(snap.GetValue("key"), qt.Equals, 99) c.Check(snap.FetchTime(), qt.Not(qt.Equals), time.Time{}) srv.setResponseJSON(rootNodeWithKeyValue("key", 101, wireconfig.IntEntry)) + time.Sleep(1 * time.Millisecond) // wait a bit to ensure fetch times don't collide client.Refresh(context.Background()) c.Check(snap.GetValue("key"), qt.Equals, 99) c.Check(client.Snapshot(nil).GetValue("key"), qt.Equals, 101) c.Check(client.Snapshot(nil).FetchTime().After(snap.FetchTime()), qt.IsTrue) } +func TestClient_GetBoolDetails(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + details := client.GetBoolValueDetails("bool30TrueAdvancedRules", true, user) + c.Assert(details.Value, qt.IsFalse) + c.Assert(details.Data.IsDefaultValue, qt.IsFalse) + c.Assert(details.Data.Error, qt.IsNil) + c.Assert(details.Data.Key, qt.Equals, "bool30TrueAdvancedRules") + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(details.Data.VariationID, qt.Equals, "385d9803") + c.Assert(details.Data.MatchedEvaluationPercentageRule, qt.IsNil) + c.Assert(details.Data.MatchedEvaluationRule.Comparator, qt.Equals, 0) + c.Assert(details.Data.MatchedEvaluationRule.ComparisonAttribute, qt.Equals, "Email") + c.Assert(details.Data.MatchedEvaluationRule.ComparisonValue, qt.Equals, "a@configcat.com, b@configcat.com") +} + +func TestClient_GetStringDetails(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + details := client.GetStringValueDetails("stringContainsDogDefaultCat", "", user) + c.Assert(details.Value, qt.Equals, "Dog") + c.Assert(details.Data.IsDefaultValue, qt.IsFalse) + c.Assert(details.Data.Error, qt.IsNil) + c.Assert(details.Data.Key, qt.Equals, "stringContainsDogDefaultCat") + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(details.Data.VariationID, qt.Equals, "d0cd8f06") + c.Assert(details.Data.MatchedEvaluationPercentageRule, qt.IsNil) + c.Assert(details.Data.MatchedEvaluationRule.Comparator, qt.Equals, 2) + c.Assert(details.Data.MatchedEvaluationRule.ComparisonAttribute, qt.Equals, "Email") + c.Assert(details.Data.MatchedEvaluationRule.ComparisonValue, qt.Equals, "@configcat.com") +} + +func TestClient_GetIntDetails(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com"} + + details := client.GetIntValueDetails("integer25One25Two25Three25FourAdvancedRules", 0, user) + c.Assert(details.Value, qt.Equals, 1) + c.Assert(details.Data.IsDefaultValue, qt.IsFalse) + c.Assert(details.Data.Error, qt.IsNil) + c.Assert(details.Data.Key, qt.Equals, "integer25One25Two25Three25FourAdvancedRules") + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(details.Data.VariationID, qt.Equals, "11634414") + c.Assert(details.Data.MatchedEvaluationPercentageRule.Percentage, qt.Equals, int64(25)) +} + +func TestClient_GetFloatDetails(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + details := client.GetFloatValueDetails("double25Pi25E25Gr25Zero", 0.0, user) + c.Assert(details.Value, qt.Equals, 5.561) + c.Assert(details.Data.IsDefaultValue, qt.IsFalse) + c.Assert(details.Data.Error, qt.IsNil) + c.Assert(details.Data.Key, qt.Equals, "double25Pi25E25Gr25Zero") + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(details.Data.VariationID, qt.Equals, "3f7826de") + c.Assert(details.Data.MatchedEvaluationPercentageRule, qt.IsNil) + c.Assert(details.Data.MatchedEvaluationRule.Comparator, qt.Equals, 2) + c.Assert(details.Data.MatchedEvaluationRule.ComparisonAttribute, qt.Equals, "Email") + c.Assert(details.Data.MatchedEvaluationRule.ComparisonValue, qt.Equals, "@configcat.com") +} + +func TestClient_GetAllDetails(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + keys := client.GetAllKeys() + details := client.GetAllValueDetails(user) + c.Assert(len(details), qt.Equals, len(keys)) +} + +func TestClient_GetDetails_Reflected_User(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + client := NewCustomClient(srv.config()) + client.Refresh(context.Background()) + + user := &struct{ attr string }{"a"} + + details := client.GetFloatValueDetails("double25Pi25E25Gr25Zero", 0.0, user) + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(srv.requestCount, qt.Equals, 1) +} + +func TestClient_Hooks_OnFlagEvaluated(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + called := make(chan struct{}) + cfg := srv.config() + cfg.Hooks = &Hooks{OnFlagEvaluated: func(details *EvaluationDetails) { + c.Assert(details.Value, qt.Equals, 5.561) + c.Assert(details.Data.IsDefaultValue, qt.IsFalse) + c.Assert(details.Data.Error, qt.IsNil) + c.Assert(details.Data.Key, qt.Equals, "double25Pi25E25Gr25Zero") + c.Assert(details.Data.User, qt.Equals, user) + c.Assert(details.Data.VariationID, qt.Equals, "3f7826de") + c.Assert(details.Data.MatchedEvaluationPercentageRule, qt.IsNil) + c.Assert(details.Data.MatchedEvaluationRule.Comparator, qt.Equals, 2) + c.Assert(details.Data.MatchedEvaluationRule.ComparisonAttribute, qt.Equals, "Email") + c.Assert(details.Data.MatchedEvaluationRule.ComparisonValue, qt.Equals, "@configcat.com") + called <- struct{}{} + }} + client := NewCustomClient(cfg) + client.Refresh(context.Background()) + + _ = client.GetFloatValue("double25Pi25E25Gr25Zero", 0.0, user) + + select { + case <-called: + case <-time.After(time.Second): + t.Fatalf("timed out") + } +} + +func TestClient_OfflineMode(t *testing.T) { + c := qt.New(t) + srv := newConfigServer(t) + srv.setResponse(configResponse{ + body: contentForIntegrationTestKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"), + }) + config := srv.config() + config.Offline = true + config.Cache = newCacheForSdkKey("PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A") + client := NewCustomClient(config) + client.Refresh(context.Background()) + + user := &UserData{Identifier: "a@configcat.com", Email: "a@configcat.com"} + + details := client.GetAllValueDetails(user) + c.Assert(len(details), qt.Equals, 16) + c.Assert(srv.requestCount, qt.Equals, 0) +} + type failingCache struct{} // get reads the configuration from the cache. @@ -557,6 +738,23 @@ func (cache failingCache) Set(ctx context.Context, key string, value []byte) err return errors.New("fake failing cache fails to set") } +type preConfCache struct { + initial []byte +} + +func newCacheForSdkKey(sdkKey string) *preConfCache { + data := []byte(contentForIntegrationTestKey(sdkKey)) + return &preConfCache{initial: data} +} + +func (cache *preConfCache) Get(ctx context.Context, key string) ([]byte, error) { + return cache.initial, nil +} + +func (cache *preConfCache) Set(ctx context.Context, key string, value []byte) error { + return nil +} + func getTestClients(t *testing.T) (*configServer, *Client) { srv := newConfigServer(t) cfg := srv.config() diff --git a/v7/configserver_test.go b/v7/configserver_test.go index a33b49a..2ca41ee 100644 --- a/v7/configserver_test.go +++ b/v7/configserver_test.go @@ -18,9 +18,10 @@ type configServer struct { key string t testing.TB - mu sync.Mutex - resp *configResponse - responses []configResponse + mu sync.Mutex + resp *configResponse + responses []configResponse + requestCount int } type configResponse struct { @@ -89,6 +90,7 @@ func (srv *configServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } srv.mu.Lock() + srv.requestCount++ resp0 := srv.resp defer srv.mu.Unlock() if resp0 == nil { diff --git a/v7/eval.go b/v7/eval.go index 2d3b74d..dc966f0 100644 --- a/v7/eval.go +++ b/v7/eval.go @@ -36,7 +36,7 @@ func (conf *config) evaluatorsForUserType(userType reflect.Type) ([]entryEvalFun return entries1.([]entryEvalFunc), nil } -type entryEvalFunc = func(id keyID, logger *leveledLogger, userv reflect.Value) (int32, string) +type entryEvalFunc = func(id keyID, logger *leveledLogger, userv reflect.Value) (valueID, string, *wireconfig.RolloutRule, *wireconfig.PercentageRule) func entryEvaluators(root *wireconfig.RootNode, userType reflect.Type) ([]entryEvalFunc, error) { tinfo, err := newUserTypeInfo(userType) @@ -59,13 +59,13 @@ func entryEvaluators(root *wireconfig.RootNode, userType reflect.Type) ([]entryE func entryEvaluator(key string, node *wireconfig.Entry, tinfo *userTypeInfo) entryEvalFunc { rules := node.RolloutRules - noUser := func(_ keyID, logger *leveledLogger, user reflect.Value) (valueID, string) { + noUser := func(_ keyID, logger *leveledLogger, user reflect.Value) (valueID, string, *wireconfig.RolloutRule, *wireconfig.PercentageRule) { if logger.enabled(LogLevelWarn) && (len(rules) > 0 || len(node.PercentageRules) > 0) { logger.Warnf("Evaluating GetValue(%s). UserObject missing! You should pass a "+ - "UserObject to GetValueForUser() in order to make targeting work properly. "+ + "UserObject to GetValue() in order to make targeting work properly. "+ "Read more: https://configcat.com/docs/advanced/user-object.", key) } - return node.ValueID, node.VariationID + return node.ValueID, node.VariationID, nil, nil } if tinfo == nil { @@ -81,7 +81,7 @@ func entryEvaluator(key string, node *wireconfig.Entry, tinfo *userTypeInfo) ent identifierInfo := tinfo.attrInfo("Identifier") keyBytes := []byte(key) - return func(id keyID, logger *leveledLogger, userv reflect.Value) (valueID, string) { + return func(id keyID, logger *leveledLogger, userv reflect.Value) (valueID, string, *wireconfig.RolloutRule, *wireconfig.PercentageRule) { if tinfo.deref { if userv.IsNil() { return noUser(id, logger, userv) @@ -100,7 +100,7 @@ func entryEvaluator(key string, node *wireconfig.Entry, tinfo *userTypeInfo) ent rule.ComparisonValue, ) } - return rule.ValueID, rule.VariationID + return rule.ValueID, rule.VariationID, rule, nil } if err != nil { if logger.enabled(LogLevelInfo) { @@ -139,11 +139,11 @@ func entryEvaluator(key string, node *wireconfig.Entry, tinfo *userTypeInfo) ent for _, rule := range node.PercentageRules { bucket += rule.Percentage if scaled < bucket { - return rule.ValueID, rule.VariationID + return rule.ValueID, rule.VariationID, nil, rule } } } - return node.ValueID, node.VariationID + return node.ValueID, node.VariationID, nil, nil } } diff --git a/v7/eval_details.go b/v7/eval_details.go new file mode 100644 index 0000000..176a41f --- /dev/null +++ b/v7/eval_details.go @@ -0,0 +1,78 @@ +package configcat + +import ( + "github.com/configcat/go-sdk/v7/internal/wireconfig" + "time" +) + +// EvaluationDetailsData holds the additional evaluation information of a feature flag or setting. +type EvaluationDetailsData struct { + Key string + VariationID string + User User + IsDefaultValue bool + Error error + FetchTime time.Time + MatchedEvaluationRule *RolloutRule + MatchedEvaluationPercentageRule *PercentageRule +} + +// EvaluationDetails holds the additional evaluation information along with the value of a feature flag or setting. +type EvaluationDetails struct { + Data EvaluationDetailsData + Value interface{} +} + +// BoolEvaluationDetails holds the additional evaluation information along with the value of a bool flag. +type BoolEvaluationDetails struct { + Data EvaluationDetailsData + Value bool +} + +// IntEvaluationDetails holds the additional evaluation information along with the value of a whole number flag. +type IntEvaluationDetails struct { + Data EvaluationDetailsData + Value int +} + +// StringEvaluationDetails holds the additional evaluation information along with the value of a string flag. +type StringEvaluationDetails struct { + Data EvaluationDetailsData + Value string +} + +// FloatEvaluationDetails holds the additional evaluation information along with the value of a decimal number flag. +type FloatEvaluationDetails struct { + Data EvaluationDetailsData + Value float64 +} + +type RolloutRule struct { + ComparisonAttribute string + ComparisonValue string + Comparator int +} + +type PercentageRule struct { + VariationID string + Percentage int64 +} + +func newPublicRolloutRuleOrNil(rule *wireconfig.RolloutRule) *RolloutRule { + if rule == nil { + return nil + } + + return &RolloutRule{ + Comparator: int(rule.Comparator), + ComparisonAttribute: rule.ComparisonAttribute, + ComparisonValue: rule.ComparisonValue} +} + +func newPublicPercentageRuleOrNil(rule *wireconfig.PercentageRule) *PercentageRule { + if rule == nil { + return nil + } + + return &PercentageRule{Percentage: rule.Percentage} +} diff --git a/v7/flag.go b/v7/flag.go index 69ca8b8..64d6f34 100644 --- a/v7/flag.go +++ b/v7/flag.go @@ -1,6 +1,7 @@ package configcat import ( + "fmt" "sync" ) @@ -12,6 +13,11 @@ type Flag interface { // GetValue returns the flag's value. It will always // return the appropriate type for the flag (never nil). GetValue(snap *Snapshot) interface{} + + // GetValueDetails returns the evaluation details + // along with the flag's value. Its Value field always + // have the appropriate type for the flag. + GetValueDetails(snap *Snapshot) EvaluationDetails } // Bool returns a representation of a boolean-valued flag. @@ -53,6 +59,14 @@ func (f BoolFlag) Get(snap *Snapshot) bool { return f.GetValue(snap).(bool) } +// GetWithDetails returns the evaluation details along with the flag's value. +// It returns BoolEvaluationDetails with the flag's default value if snap is nil +// or the key isn't in the configuration. +func (f BoolFlag) GetWithDetails(snap *Snapshot) BoolEvaluationDetails { + details := f.GetValueDetails(snap) + return BoolEvaluationDetails{Data: details.Data, Value: details.Value.(bool)} +} + // GetValue implements Flag.GetValue. func (f BoolFlag) GetValue(snap *Snapshot) interface{} { v := snap.value(f.id, f.key) @@ -62,6 +76,26 @@ func (f BoolFlag) GetValue(snap *Snapshot) interface{} { return f.defaultValue } +// GetValueDetails implements Flag.GetValueDetails. +func (f BoolFlag) GetValueDetails(snap *Snapshot) EvaluationDetails { + details := snap.evalDetailsForKeyId(f.id, f.key) + boolVal, ok := details.Value.(bool) + if !ok { + return EvaluationDetails{ + Value: f.defaultValue, + Data: EvaluationDetailsData{ + Key: f.key, + Error: fmt.Errorf("could not convert %s to bool", details.Value), + User: snap.originalUser, + FetchTime: snap.FetchTime(), + IsDefaultValue: true, + }, + } + } + details.Value = boolVal + return details +} + // Int is like Bool but for int-valued flags. func Int(key string, defaultValue int) IntFlag { return IntFlag{ @@ -91,21 +125,43 @@ func (f IntFlag) Get(snap *Snapshot) int { return f.GetValue(snap).(int) } +// GetWithDetails returns the evaluation details along with the flag's value. +// It returns IntEvaluationDetails with the flag's default value if snap is nil +// or the key isn't in the configuration. +func (f IntFlag) GetWithDetails(snap *Snapshot) IntEvaluationDetails { + details := f.GetValueDetails(snap) + return IntEvaluationDetails{Data: details.Data, Value: details.Value.(int)} +} + // GetValue implements Flag.GetValue. func (f IntFlag) GetValue(snap *Snapshot) interface{} { v := snap.value(f.id, f.key) - switch v1 := v.(type) { - case int: - return v - case float64: - // This can happen when a numeric override flag is used - // with SimplifiedConfig, which can't tell the difference - // between int and float64. - return int(v1) + if res, ok := convertInt(v); ok { + return res } return f.defaultValue } +// GetValueDetails implements Flag.GetValueDetails. +func (f IntFlag) GetValueDetails(snap *Snapshot) EvaluationDetails { + details := snap.evalDetailsForKeyId(f.id, f.key) + intVal, ok := convertInt(details.Value) + if !ok { + return EvaluationDetails{ + Value: f.defaultValue, + Data: EvaluationDetailsData{ + Key: f.key, + Error: fmt.Errorf("could not convert %s to int", details.Value), + User: snap.originalUser, + FetchTime: snap.FetchTime(), + IsDefaultValue: true, + }, + } + } + details.Value = intVal + return details +} + // String is like Bool but for string-valued flags. func String(key string, defaultValue string) StringFlag { return StringFlag{ @@ -135,6 +191,14 @@ func (f StringFlag) Get(snap *Snapshot) string { return f.GetValue(snap).(string) } +// GetWithDetails returns the evaluation details along with the flag's value. +// It returns StringEvaluationDetails with the flag's default value if snap is nil +// or the key isn't in the configuration. +func (f StringFlag) GetWithDetails(snap *Snapshot) StringEvaluationDetails { + details := f.GetValueDetails(snap) + return StringEvaluationDetails{Data: details.Data, Value: details.Value.(string)} +} + // GetValue implements Flag.GetValue. func (f StringFlag) GetValue(snap *Snapshot) interface{} { v := snap.value(f.id, f.key) @@ -144,6 +208,26 @@ func (f StringFlag) GetValue(snap *Snapshot) interface{} { return f.defaultValue } +// GetValueDetails implements Flag.GetValueDetails. +func (f StringFlag) GetValueDetails(snap *Snapshot) EvaluationDetails { + details := snap.evalDetailsForKeyId(f.id, f.key) + stringVal, ok := details.Value.(string) + if !ok { + return EvaluationDetails{ + Value: f.defaultValue, + Data: EvaluationDetailsData{ + Key: f.key, + Error: fmt.Errorf("could not convert %s to string", details.Value), + User: snap.originalUser, + FetchTime: snap.FetchTime(), + IsDefaultValue: true, + }, + } + } + details.Value = stringVal + return details +} + // Float is like Bool but for float-valued flags. func Float(key string, defaultValue float64) FloatFlag { return FloatFlag{ @@ -173,6 +257,14 @@ func (f FloatFlag) Get(snap *Snapshot) float64 { return f.GetValue(snap).(float64) } +// GetWithDetails returns the evaluation details along with the flag's value. +// It returns FloatEvaluationDetails with the flag's default value if snap is nil +// or the key isn't in the configuration. +func (f FloatFlag) GetWithDetails(snap *Snapshot) FloatEvaluationDetails { + details := f.GetValueDetails(snap) + return FloatEvaluationDetails{Data: details.Data, Value: details.Value.(float64)} +} + // GetValue implements Flag.GetValue. func (f FloatFlag) GetValue(snap *Snapshot) interface{} { v := snap.value(f.id, f.key) @@ -182,6 +274,26 @@ func (f FloatFlag) GetValue(snap *Snapshot) interface{} { return f.defaultValue } +// GetValueDetails implements Flag.GetValueDetails. +func (f FloatFlag) GetValueDetails(snap *Snapshot) EvaluationDetails { + details := snap.evalDetailsForKeyId(f.id, f.key) + floatVal, ok := details.Value.(float64) + if !ok { + return EvaluationDetails{ + Value: f.defaultValue, + Data: EvaluationDetailsData{ + Key: f.key, + Error: fmt.Errorf("could not convert %s to float64", details.Value), + User: snap.originalUser, + FetchTime: snap.FetchTime(), + IsDefaultValue: true, + }, + } + } + details.Value = floatVal + return details +} + type keyID uint32 var keyIDs struct { @@ -217,3 +329,16 @@ func numKeys() int { defer keyIDs.mu.Unlock() return int(keyIDs.max) } + +func convertInt(val interface{}) (interface{}, bool) { + switch v1 := val.(type) { + case int: + return val, true + case float64: + // This can happen when a numeric override flag is used + // with SimplifiedConfig, which can't tell the difference + // between int and float64. + return int(v1), true + } + return nil, false +} diff --git a/v7/go.mod b/v7/go.mod index 5c34a07..30c11cb 100644 --- a/v7/go.mod +++ b/v7/go.mod @@ -1,10 +1,10 @@ -module github.com/configcat/go-sdk/v7 - -go 1.13 - -require ( - github.com/blang/semver v3.5.1+incompatible - github.com/frankban/quicktest v1.11.2 - github.com/google/go-cmp v0.5.2 - github.com/sirupsen/logrus v1.4.2 -) +module github.com/configcat/go-sdk/v7 + +go 1.13 + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/frankban/quicktest v1.11.2 + github.com/google/go-cmp v0.5.2 + github.com/sirupsen/logrus v1.8.1 +) diff --git a/v7/go.sum b/v7/go.sum index 3419194..971ec69 100644 --- a/v7/go.sum +++ b/v7/go.sum @@ -17,10 +17,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/v7/logger.go b/v7/logger.go index 7756434..cdf7666 100644 --- a/v7/logger.go +++ b/v7/logger.go @@ -1,6 +1,7 @@ package configcat import ( + "fmt" "github.com/sirupsen/logrus" ) @@ -35,13 +36,14 @@ func DefaultLogger(level LogLevel) Logger { return logger } -func newLeveledLogger(logger Logger) *leveledLogger { +func newLeveledLogger(logger Logger, hooks *Hooks) *leveledLogger { if logger == nil { logger = DefaultLogger(LogLevelWarn) } return &leveledLogger{ level: logger.GetLevel(), Logger: logger, + hooks: hooks, } } @@ -50,9 +52,17 @@ func newLeveledLogger(logger Logger) *leveledLogger { // and thus avoid the allocation for the arguments. type leveledLogger struct { level LogLevel + hooks *Hooks Logger } func (log *leveledLogger) enabled(level LogLevel) bool { return level <= log.level } + +func (log *leveledLogger) Errorf(format string, args ...interface{}) { + if log.hooks != nil && log.hooks.OnError != nil { + go log.hooks.OnError(fmt.Errorf(format, args...)) + } + log.Logger.Errorf(format, args...) +} diff --git a/v7/snapshot.go b/v7/snapshot.go index 181a7f8..a1923d5 100644 --- a/v7/snapshot.go +++ b/v7/snapshot.go @@ -1,7 +1,9 @@ package configcat import ( + "errors" "fmt" + "github.com/configcat/go-sdk/v7/internal/wireconfig" "reflect" "strings" "sync" @@ -15,13 +17,14 @@ import ( // A nil snapshot is OK to use and acts like a configuration // with no keys. type Snapshot struct { - logger *leveledLogger - config *config - user reflect.Value - allKeys []string + logger *leveledLogger + config *config + hooks *Hooks + originalUser User + user reflect.Value + allKeys []string - // values holds the value for each possible value ID, as stored in - // config.values. + // values holds the value for each possible value ID, as stored in config.values. values []interface{} // precalc holds precalculated value IDs as stored in config.precalc. @@ -78,8 +81,8 @@ func NewSnapshot(logger Logger, values map[string]interface{}) (*Snapshot, error keys = append(keys, name) } // Save some allocations by using the same closure for every key. - eval := func(id keyID, logger *leveledLogger, userv reflect.Value) (valueID, string) { - return valueID(id) + 1, "" + eval := func(id keyID, logger *leveledLogger, userv reflect.Value) (valueID, string, *wireconfig.RolloutRule, *wireconfig.PercentageRule) { + return valueID(id) + 1, "", nil, nil } evaluators := make([]entryEvalFunc, len(valuesSlice)) precalc := make([]valueID, len(valuesSlice)) @@ -88,7 +91,7 @@ func NewSnapshot(logger Logger, values map[string]interface{}) (*Snapshot, error precalc[i] = valueID(i) + 1 } return &Snapshot{ - logger: newLeveledLogger(logger), + logger: newLeveledLogger(logger, nil), evaluators: evaluators, allKeys: keys, values: valuesSlice, @@ -96,21 +99,23 @@ func NewSnapshot(logger Logger, values map[string]interface{}) (*Snapshot, error }, nil } -func newSnapshot(cfg *config, user User, logger *leveledLogger) *Snapshot { +func newSnapshot(cfg *config, user User, logger *leveledLogger, hooks *Hooks) *Snapshot { if cfg != nil && (user == nil || user == cfg.defaultUser) { return cfg.defaultUserSnapshot } - return _newSnapshot(cfg, user, logger) + return _newSnapshot(cfg, user, logger, hooks) } // _newSnapshot is like newSnapshot except that it doesn't check // whether user is nil. It should only be used by the parseConfig code // for initializing config.noUserSnapshot. -func _newSnapshot(cfg *config, user User, logger *leveledLogger) *Snapshot { +func _newSnapshot(cfg *config, user User, logger *leveledLogger, hooks *Hooks) *Snapshot { snap := &Snapshot{ - config: cfg, - user: reflect.ValueOf(user), - logger: logger, + config: cfg, + user: reflect.ValueOf(user), + logger: logger, + originalUser: user, + hooks: hooks, } var userType reflect.Type if user != nil { @@ -144,17 +149,16 @@ func (snap *Snapshot) WithUser(user User) *Snapshot { if user == nil || user == snap.config.defaultUser { return snap.config.defaultUserSnapshot } - return newSnapshot(snap.config, user, snap.logger) + return newSnapshot(snap.config, user, snap.logger, snap.hooks) } func (snap *Snapshot) value(id keyID, key string) interface{} { if snap == nil { return nil } - if snap.logger.enabled(LogLevelInfo) || int(id) >= len(snap.precalc) { - // We want to see logs or we don't know about the key so use the slow path. - val, _ := snap.valueAndVariationID(id, key) - return val + if snap.logger.enabled(LogLevelInfo) || int(id) >= len(snap.precalc) || (snap.hooks != nil && snap.hooks.OnFlagEvaluated != nil) { + // We want to see logs, or we don't know about the key so use the slow path. + return snap.valueFromDetails(id, key) } valID := snap.precalc[id] if valID > 0 { @@ -168,8 +172,7 @@ func (snap *Snapshot) value(id keyID, key string) interface{} { } // Use the default implementation which will do the // appropriate logging for us. - val, _ := snap.valueAndVariationID(id, key) - return val + return snap.valueFromDetails(id, key) } // Look up the key in the cache. cacheIndex := int(-valID - 1) @@ -180,8 +183,8 @@ func (snap *Snapshot) value(id keyID, key string) interface{} { // logging of rule evaluation. return snap.valueForID(valID) } - val, _ := snap.valueAndVariationID(id, key) - return val + + return snap.valueFromDetails(id, key) } func (snap *Snapshot) initCache() { @@ -190,27 +193,28 @@ func (snap *Snapshot) initCache() { }) } -// GetVariationID returns the variation ID that will be used for the given key -// with respect to the current user, or the empty string if none is found. -func (snap *Snapshot) GetVariationID(key string) string { - _, variationID := snap.valueAndVariationID(idForKey(key, false), key) - return variationID +func (snap *Snapshot) valueFromDetails(id keyID, key string) interface{} { + if value, _, _, _, err := snap.details(id, key); err == nil { + return value + } + return nil } -func (snap *Snapshot) valueAndVariationID(id keyID, key string) (interface{}, string) { +func (snap *Snapshot) details(id keyID, key string) (interface{}, string, *wireconfig.RolloutRule, *wireconfig.PercentageRule, error) { if snap == nil { - return nil, "" + return nil, "", nil, nil, errors.New("snapshot is nil") } var eval entryEvalFunc if int(id) < len(snap.evaluators) { eval = snap.evaluators[id] } if eval == nil { - snap.logger.Errorf("error getting value: value not found for key %s."+ + err := fmt.Errorf("error getting value: value not found for key %s."+ " Here are the available keys: %s", key, strings.Join(snap.GetAllKeys(), ",")) - return nil, "" + snap.logger.Errorf("%v", err) + return nil, "", nil, nil, err } - valID, variationID := eval(id, snap.logger, snap.user) + valID, varID, rollout, percentage := eval(id, snap.logger, snap.user) val := snap.valueForID(valID) if snap.logger.enabled(LogLevelInfo) { snap.logger.Infof("Returning %v=%v.", key, val) @@ -220,7 +224,42 @@ func (snap *Snapshot) valueAndVariationID(id keyID, key string) (interface{}, st cacheIndex := -v - 1 atomic.StoreInt32(&snap.cache[cacheIndex], valID) } - return val, variationID + if snap.hooks != nil && snap.hooks.OnFlagEvaluated != nil { + go snap.hooks.OnFlagEvaluated(&EvaluationDetails{ + Value: val, + Data: EvaluationDetailsData{ + Key: key, + VariationID: varID, + User: snap.originalUser, + FetchTime: snap.FetchTime(), + MatchedEvaluationRule: newPublicRolloutRuleOrNil(rollout), + MatchedEvaluationPercentageRule: newPublicPercentageRuleOrNil(percentage), + }, + }) + } + return val, varID, rollout, percentage, nil +} + +func (snap *Snapshot) evalDetailsForKeyId(id keyID, key string) EvaluationDetails { + value, varID, rollout, percentage, err := snap.details(id, key) + if err != nil { + return EvaluationDetails{Value: nil, Data: EvaluationDetailsData{ + Key: key, + User: snap.originalUser, + IsDefaultValue: true, + Error: err, + FetchTime: snap.config.fetchTime, + }} + } + + return EvaluationDetails{Value: value, Data: EvaluationDetailsData{ + Key: key, + VariationID: varID, + User: snap.originalUser, + FetchTime: snap.config.fetchTime, + MatchedEvaluationRule: newPublicRolloutRuleOrNil(rollout), + MatchedEvaluationPercentageRule: newPublicPercentageRuleOrNil(percentage), + }} } // valueForID returns the actual value corresponding to @@ -237,11 +276,27 @@ func (snap *Snapshot) valueForID(id valueID) interface{} { // one of the typed feature flag functions. For example: // // someFlag := configcat.Bool("someFlag", false) -// value := someFlag.Get(snap) +// value := someFlag.Get(snap) func (snap *Snapshot) GetValue(key string) interface{} { return snap.value(idForKey(key, false), key) } +// GetValueDetails returns the value and evaluation details of a feature flag or setting +// with respect to the current user, or nil if none is found. +func (snap *Snapshot) GetValueDetails(key string) EvaluationDetails { + return snap.evalDetailsForKeyId(idForKey(key, false), key) +} + +// GetAllValueDetails returns values along with evaluation details of all feature flags and settings. +func (snap *Snapshot) GetAllValueDetails() []EvaluationDetails { + keys := snap.GetAllKeys() + details := make([]EvaluationDetails, 0, len(keys)) + for _, key := range keys { + details = append(details, snap.GetValueDetails(key)) + } + return details +} + // GetKeyValueForVariationID returns the key and value that // are associated with the given variation ID. If the // variation ID isn't found, it returns "", nil. @@ -257,8 +312,19 @@ func (snap *Snapshot) GetKeyValueForVariationID(id string) (string, interface{}) return key, value } +// GetVariationID returns the variation ID that will be used for the given key +// with respect to the current user, or the empty string if none is found. +// Deprecated: This method is obsolete and will be removed in a future major version. Please use GetValueDetails instead. +func (snap *Snapshot) GetVariationID(key string) string { + if _, varID, _, _, err := snap.details(idForKey(key, false), key); err == nil { + return varID + } + return "" +} + // GetVariationIDs returns all variation IDs in the current configuration // that apply to the current user. +// Deprecated: This method is obsolete and will be removed in a future major version. Please use GetAllValueDetails instead. func (snap *Snapshot) GetVariationIDs() []string { if snap == nil || snap.config == nil { return nil @@ -267,7 +333,7 @@ func (snap *Snapshot) GetVariationIDs() []string { ids := make([]string, 0, len(keys)) for _, key := range keys { id := idForKey(key, false) - _, varID := snap.evaluators[id](id, snap.logger, snap.user) + _, varID, _, _ := snap.evaluators[id](id, snap.logger, snap.user) ids = append(ids, varID) } return ids diff --git a/v7/snapshot_test.go b/v7/snapshot_test.go index 5e6e911..1c5810d 100644 --- a/v7/snapshot_test.go +++ b/v7/snapshot_test.go @@ -61,7 +61,7 @@ var loggingTests = []struct { key: "key", expectValue: "defaultValue", expectLogs: []string{ - "WARN: Evaluating GetValue(key). UserObject missing! You should pass a UserObject to GetValueForUser() in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object.", + "WARN: Evaluating GetValue(key). UserObject missing! You should pass a UserObject to GetValue() in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object.", "INFO: Returning key=defaultValue.", }, }, { @@ -115,7 +115,7 @@ var loggingTests = []struct { key: "key", expectValue: "defaultValue", expectLogs: []string{ - "WARN: Evaluating GetValue(key). UserObject missing! You should pass a UserObject to GetValueForUser() in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object.", + "WARN: Evaluating GetValue(key). UserObject missing! You should pass a UserObject to GetValue() in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object.", "INFO: Returning key=defaultValue.", }, }, {