Skip to content

Commit

Permalink
Evaluation details (#56)
Browse files Browse the repository at this point in the history
* Add GetEvaluationDetails()

* Hooks

* GetAllValueDetails() & Offline mode

* Offline test

* Minor consolidation

* Update eval.go

* Update snapshot_test.go

* Comments

* EvaluationDetailsMeta -> EvaluationDetailsData

* Update flag.go
  • Loading branch information
z4kn4fein authored Jan 9, 2023
1 parent ecd8ca4 commit a7a1407
Show file tree
Hide file tree
Showing 13 changed files with 655 additions and 86 deletions.
55 changes: 42 additions & 13 deletions v7/config_fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type configFetcher struct {
defaultUser User
pollingIdentifier string
overrides *FlagOverrides
hooks *Hooks
offline bool

ctx context.Context
ctxCancel func()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions v7/config_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
61 changes: 59 additions & 2 deletions v7/configcat_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
}
Loading

0 comments on commit a7a1407

Please sign in to comment.