featurehub-sdk 1.3.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/CLAUDE.md +85 -0
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +18 -1
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +20 -8
  7. data/README.md +306 -119
  8. data/examples/rails_example/.ruby-version +1 -1
  9. data/examples/rails_example/Dockerfile +1 -1
  10. data/examples/sinatra/.dockerignore +7 -0
  11. data/examples/sinatra/.ruby-version +1 -1
  12. data/examples/sinatra/Dockerfile +14 -25
  13. data/examples/sinatra/Gemfile +5 -4
  14. data/examples/sinatra/Gemfile.lock +40 -32
  15. data/examples/sinatra/app/application.rb +21 -9
  16. data/examples/sinatra/docker-compose.yaml +24 -0
  17. data/examples/sinatra/feature-flags.yaml +6 -0
  18. data/examples/sinatra/sinatra.iml +35 -14
  19. data/examples/sinatra/start.sh +2 -0
  20. data/featurehub-sdk.gemspec +4 -1
  21. data/lib/feature_hub/sdk/context.rb +28 -7
  22. data/lib/feature_hub/sdk/feature_hub_config.rb +68 -12
  23. data/lib/feature_hub/sdk/feature_repository.rb +52 -13
  24. data/lib/feature_hub/sdk/{feature_state.rb → feature_state_holder.rb} +13 -9
  25. data/lib/feature_hub/sdk/interceptors.rb +10 -6
  26. data/lib/feature_hub/sdk/internal_feature_repository.rb +7 -3
  27. data/lib/feature_hub/sdk/local_yaml_interceptor.rb +99 -0
  28. data/lib/feature_hub/sdk/local_yaml_store.rb +71 -0
  29. data/lib/feature_hub/sdk/poll_edge_service.rb +10 -15
  30. data/lib/feature_hub/sdk/raw_update_feature_listener.rb +19 -0
  31. data/lib/feature_hub/sdk/redis_session_store.rb +130 -0
  32. data/lib/feature_hub/sdk/strategy_attributes.rb +7 -0
  33. data/lib/feature_hub/sdk/streaming_edge_service.rb +5 -7
  34. data/lib/feature_hub/sdk/version.rb +1 -10
  35. data/lib/featurehub-sdk.rb +5 -1
  36. data/sig/feature_hub/featurehub.rbs +127 -28
  37. metadata +27 -5
@@ -3,7 +3,7 @@
3
3
  module FeatureHub
4
4
  module Sdk
5
5
  # represents internal state of a feature
6
- class FeatureState
6
+ class FeatureStateHolder
7
7
  attr_reader :key, :internal_feature_state, :encoded_strategies
8
8
 
9
9
  def initialize(key, repo, feature_state = nil, parent_state = nil, ctx = nil)
@@ -30,6 +30,10 @@ module FeatureHub
30
30
  !(fs.empty? || fs["l"].nil?)
31
31
  end
32
32
 
33
+ def present?
34
+ exists?
35
+ end
36
+
33
37
  def id
34
38
  exists? ? @internal_feature_state["id"] : nil
35
39
  end
@@ -40,7 +44,7 @@ module FeatureHub
40
44
  end
41
45
 
42
46
  def with_context(ctx)
43
- FeatureState.new(@key, @repo, nil, self, ctx)
47
+ FeatureStateHolder.new(@key, @repo, nil, self, ctx)
44
48
  end
45
49
 
46
50
  def update_feature_state(feature_state)
@@ -61,19 +65,19 @@ module FeatureHub
61
65
  end
62
66
 
63
67
  def string
64
- get_value("STRING")
68
+ get_value(FeatureValueType::STRING)
65
69
  end
66
70
 
67
71
  def number
68
- get_value("NUMBER")
72
+ get_value(FeatureValueType::NUMBER)
69
73
  end
70
74
 
71
75
  def raw_json
72
- get_value("JSON")
76
+ get_value(FeatureValueType::JSON)
73
77
  end
74
78
 
75
79
  def boolean
76
- get_value("BOOLEAN")
80
+ get_value(FeatureValueType::BOOLEAN)
77
81
  end
78
82
 
79
83
  def flag
@@ -108,9 +112,9 @@ module FeatureHub
108
112
 
109
113
  def get_value(feature_type)
110
114
  unless locked?
111
- intercept = @repo.find_interceptor(@key)
115
+ matched, intercept_value = @repo.find_interceptor(@key, top_feature_state.feature_state)
112
116
 
113
- return intercept.cast(feature_type) if intercept
117
+ return intercept_value if matched
114
118
  end
115
119
 
116
120
  fs = top_feature_state
@@ -124,7 +128,7 @@ module FeatureHub
124
128
  if @ctx
125
129
  matched = @repo.apply(fs.encoded_strategies, @key, fs.id, @ctx)
126
130
 
127
- return FeatureHub::Sdk::InterceptorValue.new(matched.value).cast(feature_type) if matched.matched
131
+ return matched.value if matched.matched
128
132
  end
129
133
 
130
134
  state["value"]
@@ -12,9 +12,9 @@ module FeatureHub
12
12
  return @val if expected_type.nil? || @val.nil?
13
13
 
14
14
  case expected_type
15
- when "BOOLEAN"
15
+ when FeatureValueType::BOOLEAN
16
16
  @val.to_s.downcase.strip == "true"
17
- when "NUMBER"
17
+ when FeatureValueType::NUMBER
18
18
  @val.to_s.to_f
19
19
  else
20
20
  @val.to_s
@@ -25,7 +25,11 @@ module FeatureHub
25
25
  # Holds the pattern for a value based interceptor, which could come from a file, or whatever
26
26
  # they are not typed
27
27
  class ValueInterceptor
28
- def intercepted_value(feature_key); end
28
+ def intercepted_value(_feature_key, _repository, _feature_state)
29
+ [false, nil]
30
+ end
31
+
32
+ def close; end
29
33
  end
30
34
 
31
35
  # An example of a value interceptor that uses environment variables
@@ -35,13 +39,13 @@ module FeatureHub
35
39
  @enabled = ENV.fetch("FEATUREHUB_OVERRIDE_FEATURES", "false") == "true"
36
40
  end
37
41
 
38
- def intercepted_value(feature_key)
42
+ def intercepted_value(feature_key, _repository, _feature_state)
39
43
  if @enabled
40
44
  found = ENV.fetch("FEATUREHUB_#{sanitize_feature_name(feature_key.to_s)}", nil)
41
- return InterceptorValue.new(found) unless found.nil?
45
+ return [true, found] unless found.nil?
42
46
  end
43
47
 
44
- nil
48
+ [false, nil]
45
49
  end
46
50
 
47
51
  private
@@ -8,8 +8,12 @@ module FeatureHub
8
8
  nil
9
9
  end
10
10
 
11
- def find_interceptor(_feature_value)
12
- nil
11
+ def value(_key, default_value = nil, _attrs = nil)
12
+ default_value
13
+ end
14
+
15
+ def find_interceptor(_feature_key, _feature_state = nil)
16
+ [false, nil]
13
17
  end
14
18
 
15
19
  def ready?
@@ -22,7 +26,7 @@ module FeatureHub
22
26
  Applied.new(false, nil)
23
27
  end
24
28
 
25
- def notify(status, data); end
29
+ def notify(status, data, source = "unknown"); end
26
30
  end
27
31
  end
28
32
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "concurrent-ruby"
6
+
7
+ module FeatureHub
8
+ module Sdk
9
+ # Reads feature flag overrides from a local YAML file.
10
+ # The file path is read from FEATUREHUB_LOCAL_YAML or defaults to featurehub-features.yaml.
11
+ # Pass watch: true to reload the file automatically when it changes.
12
+ # Expected format:
13
+ # flagValues:
14
+ # MY_FLAG: true
15
+ # MY_STRING: "hello"
16
+ class LocalYamlValueInterceptor < ValueInterceptor
17
+ def initialize(opts = nil)
18
+ super()
19
+ opts ||= {}
20
+ @yaml_file = opts[:filename] || ENV.fetch("FEATUREHUB_LOCAL_YAML", "featurehub-features.yaml")
21
+ @logger = opts[:logger]
22
+ @mutex = Mutex.new
23
+ @flag_values = load_flag_values(@yaml_file)
24
+ @logger&.debug("[featurehubsdk] loaded #{@flag_values.size} feature override(s) from #{@yaml_file}")
25
+ @watcher = nil
26
+
27
+ return unless opts[:watch]
28
+
29
+ @last_mtime = File.exist?(@yaml_file) ? File.mtime(@yaml_file) : nil
30
+ watch_interval = opts[:watch_interval] || 5
31
+ @watcher = Concurrent::TimerTask.new(execution_interval: watch_interval, run_now: false) do
32
+ reload_if_changed
33
+ end
34
+ @watcher.execute
35
+ end
36
+
37
+ def intercepted_value(feature_key, _repository, feature_state)
38
+ key = feature_key.to_s
39
+ flag_values = @mutex.synchronize { @flag_values }
40
+ return [false, nil] unless flag_values.key?(key)
41
+
42
+ value = flag_values[key]
43
+
44
+ return [false, nil] if feature_state && yaml_value_type(value) != feature_state["type"]
45
+
46
+ [true, cast_value(value)]
47
+ end
48
+
49
+ def close
50
+ return if @watcher.nil?
51
+
52
+ @watcher.shutdown
53
+ @watcher = nil
54
+ end
55
+
56
+ private
57
+
58
+ def reload_if_changed
59
+ return unless File.exist?(@yaml_file)
60
+
61
+ current_mtime = File.mtime(@yaml_file)
62
+ return if current_mtime == @last_mtime
63
+
64
+ @last_mtime = current_mtime
65
+ new_values = load_flag_values(@yaml_file)
66
+ @logger&.debug("[featurehubsdk] reloaded #{new_values.size} feature override(s) from #{@yaml_file}")
67
+ @mutex.synchronize { @flag_values = new_values }
68
+ end
69
+
70
+ def load_flag_values(yaml_file)
71
+ return {} unless File.exist?(yaml_file)
72
+
73
+ data = YAML.safe_load(File.read(yaml_file))
74
+ data&.fetch("flagValues", {}) || {}
75
+ rescue StandardError
76
+ {}
77
+ end
78
+
79
+ def yaml_value_type(value)
80
+ case value
81
+ when true, false
82
+ FeatureValueType::BOOLEAN
83
+ when Integer, Float
84
+ FeatureValueType::NUMBER
85
+ when String
86
+ FeatureValueType::STRING
87
+ else
88
+ FeatureValueType::JSON
89
+ end
90
+ end
91
+
92
+ def cast_value(value)
93
+ return value.to_f if value.is_a?(Integer) || value.is_a?(Float)
94
+
95
+ value
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module FeatureHub
8
+ module Sdk
9
+ # Reads feature flags from a local YAML file and loads them into a FeatureHubRepository,
10
+ # allowing the SDK to operate without a FeatureHub Edge server.
11
+ # Implements RawUpdateFeatureListener but silently ignores all incoming update callbacks —
12
+ # the file is the single source of truth and is read exactly once at initialization.
13
+ #
14
+ # Expected YAML format (same as LocalYamlValueInterceptor):
15
+ # flagValues:
16
+ # MY_FLAG: true
17
+ # MY_STRING: "hello"
18
+ # MY_NUMBER: 42
19
+ class LocalYamlStore < RawUpdateFeatureListener
20
+ SOURCE = "local-yaml"
21
+
22
+ def initialize(repository, filename = nil)
23
+ super()
24
+ @environment_id = SecureRandom.uuid
25
+ yaml_file = filename || ENV.fetch("FEATUREHUB_LOCAL_YAML", "featurehub-features.yaml")
26
+ features = load_features(yaml_file)
27
+ repository.notify("features", features, SOURCE) if features
28
+ end
29
+
30
+ private
31
+
32
+ def load_features(yaml_file)
33
+ return nil unless File.exist?(yaml_file)
34
+
35
+ data = YAML.safe_load(File.read(yaml_file))
36
+ flag_values = data&.fetch("flagValues", {}) || {}
37
+ flag_values.map { |key, value| build_feature_state(key.to_s, value) }
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def build_feature_state(key, value)
43
+ {
44
+ "id" => SecureRandom.uuid,
45
+ "key" => key,
46
+ "l" => false,
47
+ "version" => 1,
48
+ "type" => feature_type(value),
49
+ "value" => cast_value(value),
50
+ "environmentId" => @environment_id
51
+ }
52
+ end
53
+
54
+ def feature_type(value)
55
+ case value
56
+ when true, false then FeatureValueType::BOOLEAN
57
+ when Integer, Float then FeatureValueType::NUMBER
58
+ when String then FeatureValueType::STRING
59
+ else FeatureValueType::JSON
60
+ end
61
+ end
62
+
63
+ def cast_value(value)
64
+ return value.to_f if value.is_a?(Integer) || value.is_a?(Float)
65
+ return value.to_json if value.is_a?(Hash) || value.is_a?(Array)
66
+
67
+ value
68
+ end
69
+ end
70
+ end
71
+ end
@@ -10,18 +10,13 @@ module FeatureHub
10
10
  module Sdk
11
11
  # uses a periodic polling mechanism to get updates
12
12
  class PollingEdgeService < EdgeService
13
- attr_reader :repository, :api_keys, :edge_url, :interval, :stopped, :etag, :cancel, :sha_context
13
+ attr_reader :api_keys, :edge_url, :interval, :stopped, :etag, :cancel, :sha_context
14
14
 
15
15
  def initialize(repository, api_keys, edge_url, interval, logger = nil)
16
- super(repository, api_keys, edge_url)
16
+ super(repository, api_keys, edge_url, logger)
17
17
 
18
- @repository = repository
19
- @api_keys = api_keys
20
- @edge_url = edge_url
21
18
  @interval = interval
22
19
 
23
- @logger = logger || FeatureHub::Sdk.default_logger
24
-
25
20
  @task = nil
26
21
  @cancel = false
27
22
  @context = nil
@@ -73,7 +68,7 @@ module FeatureHub
73
68
  def poll_with_interval
74
69
  return if @cancel || !@task.nil? || @stopped
75
70
 
76
- @logger.debug("starting polling for #{determine_request_url}")
71
+ @logger&.debug("starting polling for #{determine_request_url}")
77
72
  @task = Concurrent::TimerTask.new(execution_interval: @interval, run_now: false) do
78
73
  get_updates
79
74
  end
@@ -112,7 +107,7 @@ module FeatureHub
112
107
  headers["x-featurehub"] = @context unless @context.nil?
113
108
  headers["if-none-match"] = @etag unless @etag.nil?
114
109
 
115
- @logger.debug("polling for #{url}")
110
+ @logger&.debug("polling for #{url}")
116
111
  resp = @conn.get url, {}, headers
117
112
  case resp.status
118
113
  when 200
@@ -120,14 +115,14 @@ module FeatureHub
120
115
  when 236
121
116
  stopped_task
122
117
  success(resp)
123
- when 404 # no such key
124
- @repository.notify("failed", nil)
118
+ when 404, 400 # no such key
119
+ @repository.notify("failed", nil, "polling")
125
120
  cancel_task
126
- @logger.error("featurehub: key does not exist, stopping polling")
121
+ @logger&.error("featurehub: key does not exist, stopping polling")
127
122
  when 503 # dacha busy
128
- @logger.debug("featurehub: dacha is busy, trying again")
123
+ @logger&.debug("featurehub: dacha is busy, trying again")
129
124
  else
130
- @logger.debug("featurehub: unknown error #{resp.status}")
125
+ @logger&.debug("featurehub: unknown error #{resp.status}") if resp.status != 304
131
126
  end
132
127
  end
133
128
 
@@ -143,7 +138,7 @@ module FeatureHub
143
138
 
144
139
  def process_results(data)
145
140
  data.each do |environment|
146
- @repository.notify("features", environment["features"]) if environment
141
+ @repository.notify("features", environment["features"], "polling") if environment
147
142
  end
148
143
  end
149
144
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatureHub
4
+ module Sdk
5
+ # Base class for listening to raw feature update events from edge services.
6
+ # Subclass and override the methods you care about.
7
+ class RawUpdateFeatureListener
8
+ def delete_feature(_feature, _source); end
9
+
10
+ def process_updates(_features, _source); end
11
+
12
+ def process_update(_feature, _source); end
13
+
14
+ def close; end
15
+
16
+ def config_changed; end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "concurrent-ruby"
5
+
6
+ module FeatureHub
7
+ module Sdk
8
+ # Persists feature values from a FeatureHubRepository in Redis so they survive
9
+ # process restarts and are shared across multiple processes.
10
+ #
11
+ # WARNING: Do not use with server-evaluated features. Each server-evaluated
12
+ # context sends different resolved values; storing them in a shared Redis key
13
+ # will cause processes to overwrite each other's feature states.
14
+ #
15
+ # On initialization the store checks Redis for previously saved features and
16
+ # replays them into the repository. It then listens for live updates via
17
+ # RawUpdateFeatureListener and writes newer versions back to Redis. A periodic
18
+ # timer re-reads all features from Redis so that updates published by other
19
+ # processes are picked up automatically.
20
+ #
21
+ # Options (symbol keys):
22
+ # :namespace - Redis db index (default: 0)
23
+ # :prefix - key prefix for all Redis keys (default: "featurehub")
24
+ # :timeout - seconds between periodic reloads (default: 30)
25
+ class RedisSessionStore < RawUpdateFeatureListener
26
+ SOURCE = "redis-store"
27
+
28
+ def initialize(connection_string, repository, opts = nil)
29
+ super()
30
+
31
+ opts ||= {}
32
+ @repository = repository
33
+ @prefix = opts[:prefix] || "featurehub"
34
+ @timeout = opts[:timeout] || 30
35
+ @namespace = opts[:namespace] || 0
36
+ @password = opts[:password]
37
+ @logger = opts[:logger]
38
+ @task = nil
39
+
40
+ return unless redis_available?
41
+
42
+ redis_opts = { url: connection_string, db: @namespace }
43
+ redis_opts[:password] = @password if @password
44
+ @redis = Redis.new(**redis_opts)
45
+ load_from_redis
46
+ start_timer
47
+ end
48
+
49
+ def process_updates(features, source)
50
+ return if source == SOURCE || !redis_available?
51
+
52
+ features.each { |f| store_feature(f) }
53
+ end
54
+
55
+ def process_update(feature, source)
56
+ return if source == SOURCE || !redis_available?
57
+
58
+ store_feature(feature)
59
+ end
60
+
61
+ def delete_feature(feature, source)
62
+ return if source == SOURCE || !redis_available? || !feature["id"]
63
+
64
+ @redis.srem(ids_key, feature["id"])
65
+ @redis.del(feature_key(feature["id"]))
66
+ end
67
+
68
+ def close
69
+ return if @task.nil?
70
+
71
+ @task.shutdown
72
+ @task = nil
73
+ end
74
+
75
+ private
76
+
77
+ def redis_available?
78
+ @redis_available ||= begin
79
+ require "redis"
80
+ true
81
+ rescue LoadError
82
+ false
83
+ end
84
+ end
85
+
86
+ def load_from_redis
87
+ ids = @redis.smembers(ids_key)
88
+ return if ids.empty?
89
+
90
+ features = ids.filter_map do |id|
91
+ json = @redis.get(feature_key(id))
92
+ JSON.parse(json) if json
93
+ end
94
+
95
+ return if features.empty?
96
+
97
+ @logger&.debug("[featurehubsdk] loading #{features.size} feature(s) from redis")
98
+ @repository.notify("features", features, SOURCE)
99
+ end
100
+
101
+ def start_timer
102
+ @task = Concurrent::TimerTask.new(execution_interval: @timeout, run_now: false) do
103
+ load_from_redis
104
+ end
105
+ @task.execute
106
+ end
107
+
108
+ def store_feature(feature)
109
+ return unless feature && feature["id"] && feature["key"]
110
+
111
+ existing_json = @redis.get(feature_key(feature["id"]))
112
+ if existing_json
113
+ existing = JSON.parse(existing_json)
114
+ return if existing["version"].to_i >= feature["version"].to_i
115
+ end
116
+
117
+ @redis.sadd(ids_key, feature["id"])
118
+ @redis.set(feature_key(feature["id"]), feature.to_json)
119
+ end
120
+
121
+ def ids_key
122
+ "#{@prefix}_ids"
123
+ end
124
+
125
+ def feature_key(id)
126
+ "#{@prefix}_#{id}"
127
+ end
128
+ end
129
+ end
130
+ end
@@ -213,6 +213,13 @@ module FeatureHub
213
213
  Embedded = "embedded"
214
214
  end
215
215
 
216
+ module FeatureValueType
217
+ BOOLEAN = "BOOLEAN"
218
+ JSON = "JSON"
219
+ STRING = "STRING"
220
+ NUMBER = "NUMBER"
221
+ end
222
+
216
223
  module StrategyAttributePlatformName
217
224
  Linux = "linux"
218
225
  Windows = "windows"
@@ -7,16 +7,14 @@ module FeatureHub
7
7
  module Sdk
8
8
  # provides a streaming service
9
9
  class StreamingEdgeService < FeatureHub::Sdk::EdgeService
10
- attr_reader :repository, :sse_client, :url, :stopped
10
+ attr_reader :sse_client, :url, :stopped
11
11
 
12
12
  def initialize(repository, api_keys, edge_url, logger = nil)
13
- super(repository, api_keys, edge_url)
13
+ super(repository, api_keys, edge_url, logger)
14
14
 
15
15
  @url = "#{edge_url}features/#{api_keys[0]}"
16
- @repository = repository
17
16
  @sse_client = nil
18
17
  @context = nil
19
- @logger = logger || FeatureHub::Sdk.default_logger
20
18
  end
21
19
 
22
20
  def closed
@@ -58,7 +56,7 @@ module FeatureHub
58
56
  end
59
57
 
60
58
  def start_streaming
61
- @logger.debug("streaming from #{@url}")
59
+ @logger&.debug("streaming from #{@url}")
62
60
  # we can get an error before returning the new() function and get a race condition on the close
63
61
  must_close = false
64
62
  @sse_client = SSE::Client.new(@url) do |client|
@@ -68,12 +66,12 @@ module FeatureHub
68
66
  if event.type == "config"
69
67
  process_config(json_data)
70
68
  else
71
- @repository.notify(event.type, json_data)
69
+ @repository.notify(event.type, json_data, "streaming")
72
70
  end
73
71
  end
74
72
  client.on_error do |error|
75
73
  if error.is_a?(SSE::Errors::HTTPStatusError) && (error.status == 404)
76
- @repository.notify("failure", nil)
74
+ @repository.notify("failure", nil, "streaming")
77
75
  close
78
76
  must_close = true
79
77
  end
@@ -3,15 +3,6 @@
3
3
  module FeatureHub
4
4
  # already documented elsewhere
5
5
  module Sdk
6
- VERSION = "1.3.0"
7
-
8
- def default_logger
9
- log = ::Logger.new($stdout)
10
- log.level = ::Logger::WARN
11
- log.progname = "featurehub-sdk"
12
- log
13
- end
14
-
15
- module_function :default_logger
6
+ VERSION = "2.0.1"
16
7
  end
17
8
  end
@@ -9,9 +9,13 @@ require_relative "feature_hub/sdk/context"
9
9
  require_relative "feature_hub/sdk/feature_hub_config"
10
10
  require_relative "feature_hub/sdk/internal_feature_repository"
11
11
  require_relative "feature_hub/sdk/feature_repository"
12
- require_relative "feature_hub/sdk/feature_state"
12
+ require_relative "feature_hub/sdk/feature_state_holder"
13
13
  require_relative "feature_hub/sdk/interceptors"
14
14
  require_relative "feature_hub/sdk/strategy_attributes"
15
+ require_relative "feature_hub/sdk/local_yaml_interceptor"
16
+ require_relative "feature_hub/sdk/raw_update_feature_listener"
17
+ require_relative "feature_hub/sdk/local_yaml_store"
18
+ require_relative "feature_hub/sdk/redis_session_store"
15
19
  require_relative "feature_hub/sdk/impl/strategy_wrappers"
16
20
  require_relative "feature_hub/sdk/poll_edge_service"
17
21
  require_relative "feature_hub/sdk/streaming_edge_service"