Skip to content

Commit

Permalink
feat: add feature flag go sdk
Browse files Browse the repository at this point in the history
  • Loading branch information
dhawal1248 committed Jan 10, 2025
1 parent 21abca6 commit 6d22209
Show file tree
Hide file tree
Showing 13 changed files with 664 additions and 2 deletions.
43 changes: 43 additions & 0 deletions featureflags/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cache

import (
"time"

"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

// CacheEntry represents a cached item with its value and last updated timestamp
type CacheEntry struct {
Value map[string]*provider.FeatureValue
LastUpdated time.Time
}

// CacheGetResult extends CacheEntry with staleness information
type CacheGetResult struct {
*CacheEntry
IsStale bool
}

// Cache defines the interface for cache operations
type Cache interface {
Get(key string) (*CacheGetResult, bool)
Set(key string, value map[string]*provider.FeatureValue) (time.Time, error)
Delete(key string) error
Clear() error
IsEnabled() bool
}

// CacheError represents cache-specific errors
type CacheError struct {
Message string
}

func (e *CacheError) Error() string {
return e.Message
}

// CacheConfig holds configuration for the cache
type CacheConfig struct {
Enabled bool
TTLInSeconds int64
}
70 changes: 70 additions & 0 deletions featureflags/cache/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package cache_test

import (
"testing"
"time"

"github.com/rudderlabs/rudder-go-kit/featureflags/cache"
"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

func TestMemoryCache(t *testing.T) {
config := cache.CacheConfig{
Enabled: true,
TTLInSeconds: 1,
}

c := cache.NewMemoryCache(config)

testValue := map[string]*provider.FeatureValue{
"key1": {
Value: "value1",
},
}

// Test Set and Get
setTime, err := c.Set("key1", testValue)
if err != nil {
t.Errorf("Failed to set cache: %v", err)
}

result, ok := c.Get("key1")
if !ok {
t.Error("Expected cache hit, got miss")
}
if result.LastUpdated != setTime {
t.Error("LastUpdated time mismatch")
}

// Test staleness
time.Sleep(2 * time.Second)
result, ok = c.Get("key1")
if !ok {
t.Error("Expected stale cache entry")
}

// Test Delete
err = c.Delete("key1")
if err != nil {
t.Errorf("Failed to delete cache: %v", err)
}

result, ok = c.Get("key1")
if ok {
t.Error("Expected cache miss after delete")
}

// Test Clear
c.Set("key1", testValue)
c.Set("key2", testValue)

err = c.Clear()
if err != nil {
t.Errorf("Failed to clear cache: %v", err)
}

result, ok = c.Get("key1")
if ok {
t.Error("Expected cache miss after clear")
}
}
91 changes: 91 additions & 0 deletions featureflags/cache/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package cache

import (
"sync"
"time"

"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

type MemoryCache struct {
entries map[string]CacheEntry
config CacheConfig
mu sync.RWMutex
}

func NewMemoryCache(config CacheConfig) *MemoryCache {
return &MemoryCache{
entries: make(map[string]CacheEntry),
config: config,
}
}

func (c *MemoryCache) Get(key string) (*CacheGetResult, bool) {
if !c.IsEnabled() {
return nil, false
}

c.mu.RLock()
defer c.mu.RUnlock()

entry, exists := c.entries[key]
if !exists {
return nil, false
}

isStale := false
if c.config.TTLInSeconds > 0 {
expirationTime := entry.LastUpdated.Add(time.Duration(c.config.TTLInSeconds) * time.Second)
isStale = time.Now().After(expirationTime)
}

return &CacheGetResult{
CacheEntry: &entry,
IsStale: isStale,
}, true
}

func (c *MemoryCache) Set(key string, value map[string]*provider.FeatureValue) (time.Time, error) {
if !c.IsEnabled() {
return time.Time{}, &CacheError{Message: "Cache is disabled"}
}

c.mu.Lock()
defer c.mu.Unlock()

now := time.Now()
c.entries[key] = CacheEntry{
Value: value,
LastUpdated: now,
}

return now, nil
}

func (c *MemoryCache) Delete(key string) error {
if !c.IsEnabled() {
return &CacheError{Message: "Cache is disabled"}
}

c.mu.Lock()
defer c.mu.Unlock()

delete(c.entries, key)
return nil
}

func (c *MemoryCache) Clear() error {
if !c.IsEnabled() {
return &CacheError{Message: "Cache is disabled"}
}

c.mu.Lock()
defer c.mu.Unlock()

c.entries = make(map[string]CacheEntry)
return nil
}

func (c *MemoryCache) IsEnabled() bool {
return c.config.Enabled
}
161 changes: 161 additions & 0 deletions featureflags/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package featureflags

import (
"fmt"
"os"
"sync"

"github.com/rudderlabs/rudder-go-kit/featureflags/cache"
"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

// client represents the feature flag client interface
type client interface {
IsFeatureEnabled(workspaceID string, feature string) (bool, error)
IsFeatureEnabledLatest(workspaceID string, feature string) (bool, error)
GetFeatureValue(workspaceID string, feature string) (provider.FeatureValue, error)
GetFeatureValueLatest(workspaceID string, feature string) (provider.FeatureValue, error)
SetDefaultTraits(traits map[string]string)
}

var (
ffclient client
clientOnce sync.Once
)

// getFeatureFlagClient returns the singleton feature flag client instance
func getFeatureFlagClient() client {
clientOnce.Do(func() {
// read the api key from env vars and create the default cache config
apiKey := os.Getenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY")
if apiKey == "" {
panic("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY is not set")
}
defaultCacheConfig := cache.CacheConfig{
Enabled: true,
TTLInSeconds: 60,
}

// create the provider
provider, err := provider.NewProvider(provider.ProviderConfig{
Type: "flagsmith",
ApiKey: apiKey,
})
if err != nil {
panic(err)
}
ffclient = &clientImpl{
provider: provider,
cache: cache.NewMemoryCache(defaultCacheConfig),
}
})
return ffclient
}

type clientImpl struct {
provider provider.Provider
cache cache.Cache
defaultTraits map[string]string
}

// IsFeatureEnabled checks if a feature is enabled for a workspace
// Note: Result may be stale if returned from cache. Use IsFeatureEnabledLatest if stale values are not acceptable.
func (c *clientImpl) IsFeatureEnabled(workspaceID string, feature string) (bool, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, false)
if err != nil {
return false, err
}
featureval, ok := ff[feature]
if !ok {
return false, newFeatureError(fmt.Sprintf("feature %s does not exist", feature))
}
return featureval.Enabled, nil
}

// IsFeatureEnabledLatest checks if a feature is enabled for a workspace, bypassing the cache
// Note: This method always fetches fresh values from the provider(bypassing the cache), which may impact performance.
func (c *clientImpl) IsFeatureEnabledLatest(workspaceID string, feature string) (bool, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, true)
if err != nil {
return false, err
}
return ff[feature].Enabled, nil
}

// GetFeatureValue gets the value of a feature for a workspace
// Note: Result may be stale if returned from cache. Use GetFeatureValueLatest if stale values are not acceptable.
func (c *clientImpl) GetFeatureValue(workspaceID string, feature string) (provider.FeatureValue, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, false)
if err != nil {
return provider.FeatureValue{}, err
}
// create a copy of the feature value and return it
featureval := *ff[feature]
return featureval, nil
}

// GetFeatureValueLatest gets the value of a feature for a workspace, bypassing the cache
// Note: This method always fetches fresh values from the provider(bypassing the cache), which may impact performance.
func (c *clientImpl) GetFeatureValueLatest(workspaceID string, feature string) (provider.FeatureValue, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, true)
if err != nil {
return provider.FeatureValue{}, err
}
// create a copy of the feature value and return it
featureval := *ff[feature]
return featureval, nil
}

// SetDefaultTraits sets the default traits for the feature flag client
// These traits will always be used when fetching feature flags
// This function is only meant to be called once in the application lifecycle.
func (c *clientImpl) SetDefaultTraits(traits map[string]string) {
c.defaultTraits = traits
}

func (c *clientImpl) getAllFeatures(workspaceID string, traits map[string]string, skipCache bool) (map[string]*provider.FeatureValue, error) {
if !skipCache && c.cache.IsEnabled() {
if val, ok := c.cache.Get(workspaceID); ok {
if val.IsStale {
// refresh the feature flags asynchronously if the cache is stale
go c.refreshFeatureFlags(workspaceID)
}
return val.Value, nil
}
}

// Fetch from provider, if cache is disabled or cache miss
ff, err := c.provider.GetFeatureFlags(provider.ProviderParams{
WorkspaceID: workspaceID,
Traits: traits,
})
if err != nil {
return nil, err
}

// Cache the fetched feature flags if cache is enabled
if c.cache.IsEnabled() {
if _, err := c.cache.Set(workspaceID, ff); err != nil {
return nil, err
}
}
return ff, nil
}

func (c *clientImpl) refreshFeatureFlags(workspaceID string) error {
// fetch the feature flags from the provider
ff, err := c.provider.GetFeatureFlags(provider.ProviderParams{
WorkspaceID: workspaceID,
Traits: c.defaultTraits,
})
if err != nil {
return err
}

// set the feature flags in the cache
if _, err := c.cache.Set(workspaceID, ff); err != nil {
return err
}

return nil
}
1 change: 1 addition & 0 deletions featureflags/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package featureflags_test
14 changes: 14 additions & 0 deletions featureflags/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package featureflags

// featureError represents an error related to feature flag operations
type featureError struct {
Message string
}

func (e *featureError) Error() string {
return e.Message
}

func newFeatureError(message string) *featureError {
return &featureError{Message: message}
}
Loading

0 comments on commit 6d22209

Please sign in to comment.