launchdarkly-server-sdk 6.2.5 → 6.3.2

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.
@@ -16,21 +16,32 @@ module LaunchDarkly
16
16
 
17
17
  # Used internally to build the state map.
18
18
  # @private
19
- def add_flag(flag, value, variation, reason = nil, details_only_if_tracked = false)
20
- key = flag[:key]
21
- @flag_values[key] = value
19
+ def add_flag(flag_state, with_reasons, details_only_if_tracked)
20
+ key = flag_state[:key]
21
+ @flag_values[key] = flag_state[:value]
22
22
  meta = {}
23
- with_details = !details_only_if_tracked || flag[:trackEvents]
24
- if !with_details && flag[:debugEventsUntilDate]
25
- with_details = flag[:debugEventsUntilDate] > Impl::Util::current_time_millis
23
+
24
+ omit_details = false
25
+ if details_only_if_tracked
26
+ if !flag_state[:trackEvents] && !flag_state[:trackReason] && !(flag_state[:debugEventsUntilDate] && flag_state[:debugEventsUntilDate] > Impl::Util::current_time_millis)
27
+ omit_details = true
28
+ end
29
+ end
30
+
31
+ reason = (!with_reasons and !flag_state[:trackReason]) ? nil : flag_state[:reason]
32
+
33
+ if !reason.nil? && !omit_details
34
+ meta[:reason] = reason
26
35
  end
27
- if with_details
28
- meta[:version] = flag[:version]
29
- meta[:reason] = reason if !reason.nil?
36
+
37
+ if !omit_details
38
+ meta[:version] = flag_state[:version]
30
39
  end
31
- meta[:variation] = variation if !variation.nil?
32
- meta[:trackEvents] = true if flag[:trackEvents]
33
- meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate]
40
+
41
+ meta[:variation] = flag_state[:variation] if !flag_state[:variation].nil?
42
+ meta[:trackEvents] = true if flag_state[:trackEvents]
43
+ meta[:trackReason] = true if flag_state[:trackReason]
44
+ meta[:debugEventsUntilDate] = flag_state[:debugEventsUntilDate] if flag_state[:debugEventsUntilDate]
34
45
  @flag_metadata[key] = meta
35
46
  end
36
47
 
@@ -0,0 +1,117 @@
1
+ require "ldclient-rb/config"
2
+ require "ldclient-rb/expiring_cache"
3
+ require "ldclient-rb/impl/repeating_task"
4
+ require "ldclient-rb/interfaces"
5
+ require "ldclient-rb/util"
6
+
7
+ require "digest"
8
+
9
+ module LaunchDarkly
10
+ module Impl
11
+ BigSegmentMembershipResult = Struct.new(:membership, :status)
12
+
13
+ class BigSegmentStoreManager
14
+ # use this as a singleton whenever a membership query returns nil; it's safe to reuse it because
15
+ # we will never modify the membership properties after they're queried
16
+ EMPTY_MEMBERSHIP = {}
17
+
18
+ def initialize(big_segments_config, logger)
19
+ @store = big_segments_config.store
20
+ @stale_after_millis = big_segments_config.stale_after * 1000
21
+ @status_provider = BigSegmentStoreStatusProviderImpl.new(-> { get_status })
22
+ @logger = logger
23
+ @last_status = nil
24
+
25
+ if !@store.nil?
26
+ @cache = ExpiringCache.new(big_segments_config.user_cache_size, big_segments_config.user_cache_time)
27
+ @poll_worker = RepeatingTask.new(big_segments_config.status_poll_interval, 0, -> { poll_store_and_update_status }, logger)
28
+ @poll_worker.start
29
+ end
30
+ end
31
+
32
+ attr_reader :status_provider
33
+
34
+ def stop
35
+ @poll_worker.stop if !@poll_worker.nil?
36
+ @store.stop if !@store.nil?
37
+ end
38
+
39
+ def get_user_membership(user_key)
40
+ return nil if !@store
41
+ membership = @cache[user_key]
42
+ if !membership
43
+ begin
44
+ membership = @store.get_membership(BigSegmentStoreManager.hash_for_user_key(user_key))
45
+ membership = EMPTY_MEMBERSHIP if membership.nil?
46
+ @cache[user_key] = membership
47
+ rescue => e
48
+ LaunchDarkly::Util.log_exception(@logger, "Big Segment store membership query returned error", e)
49
+ return BigSegmentMembershipResult.new(nil, BigSegmentsStatus::STORE_ERROR)
50
+ end
51
+ end
52
+ poll_store_and_update_status if !@last_status
53
+ if !@last_status.available
54
+ return BigSegmentMembershipResult.new(membership, BigSegmentsStatus::STORE_ERROR)
55
+ end
56
+ BigSegmentMembershipResult.new(membership, @last_status.stale ? BigSegmentsStatus::STALE : BigSegmentsStatus::HEALTHY)
57
+ end
58
+
59
+ def get_status
60
+ @last_status || poll_store_and_update_status
61
+ end
62
+
63
+ def poll_store_and_update_status
64
+ new_status = Interfaces::BigSegmentStoreStatus.new(false, false) # default to "unavailable" if we don't get a new status below
65
+ if !@store.nil?
66
+ begin
67
+ metadata = @store.get_metadata
68
+ new_status = Interfaces::BigSegmentStoreStatus.new(true, !metadata || is_stale(metadata.last_up_to_date))
69
+ rescue => e
70
+ LaunchDarkly::Util.log_exception(@logger, "Big Segment store status query returned error", e)
71
+ end
72
+ end
73
+ @last_status = new_status
74
+ @status_provider.update_status(new_status)
75
+
76
+ new_status
77
+ end
78
+
79
+ def is_stale(timestamp)
80
+ !timestamp || ((Impl::Util.current_time_millis - timestamp) >= @stale_after_millis)
81
+ end
82
+
83
+ def self.hash_for_user_key(user_key)
84
+ Digest::SHA256.base64digest(user_key)
85
+ end
86
+ end
87
+
88
+ #
89
+ # Default implementation of the BigSegmentStoreStatusProvider interface.
90
+ #
91
+ # There isn't much to this because the real implementation is in BigSegmentStoreManager - we pass in a lambda
92
+ # that allows us to get the current status from that class. Also, the standard Observer methods such as
93
+ # add_observer are provided for us because BigSegmentStoreStatusProvider mixes in Observer, so all we need to
94
+ # to do make notifications happen is to call the Observer methods "changed" and "notify_observers".
95
+ #
96
+ class BigSegmentStoreStatusProviderImpl
97
+ include LaunchDarkly::Interfaces::BigSegmentStoreStatusProvider
98
+
99
+ def initialize(status_fn)
100
+ @status_fn = status_fn
101
+ @last_status = nil
102
+ end
103
+
104
+ def status
105
+ @status_fn.call
106
+ end
107
+
108
+ def update_status(new_status)
109
+ if !@last_status || new_status != @last_status
110
+ @last_status = new_status
111
+ changed
112
+ notify_observers(new_status)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -16,16 +16,28 @@ module LaunchDarkly
16
16
  # flag data - or nil if the flag is unknown or deleted
17
17
  # @param get_segment [Function] similar to `get_flag`, but is used to query a user segment.
18
18
  # @param logger [Logger] the client's logger
19
- def initialize(get_flag, get_segment, logger)
19
+ def initialize(get_flag, get_segment, get_big_segments_membership, logger)
20
20
  @get_flag = get_flag
21
21
  @get_segment = get_segment
22
+ @get_big_segments_membership = get_big_segments_membership
22
23
  @logger = logger
23
24
  end
24
25
 
25
- # Used internally to hold an evaluation result and the events that were generated from prerequisites. The
26
- # `detail` property is an EvaluationDetail. The `events` property can be either an array of feature request
27
- # events or nil.
28
- EvalResult = Struct.new(:detail, :events)
26
+ # Used internally to hold an evaluation result and additional state that may be accumulated during an
27
+ # evaluation. It's simpler and a bit more efficient to represent these as mutable properties rather than
28
+ # trying to use a pure functional approach, and since we're not exposing this object to any application code
29
+ # or retaining it anywhere, we don't have to be quite as strict about immutability.
30
+ #
31
+ # The big_segments_status and big_segments_membership properties are not used by the caller; they are used
32
+ # during an evaluation to cache the result of any Big Segments query that we've done for this user, because
33
+ # we don't want to do multiple queries for the same user if multiple Big Segments are referenced in the same
34
+ # evaluation.
35
+ EvalResult = Struct.new(
36
+ :detail, # the EvaluationDetail representing the evaluation result
37
+ :events, # an array of evaluation events generated by prerequisites, or nil
38
+ :big_segments_status,
39
+ :big_segments_membership
40
+ )
29
41
 
30
42
  # Helper function used internally to construct an EvaluationDetail for an error result.
31
43
  def self.error_result(errorKind, value = nil)
@@ -42,30 +54,38 @@ module LaunchDarkly
42
54
  # evaluated; the caller is responsible for constructing the feature event for the top-level evaluation
43
55
  # @return [EvalResult] the evaluation result
44
56
  def evaluate(flag, user, event_factory)
57
+ result = EvalResult.new
45
58
  if user.nil? || user[:key].nil?
46
- return EvalResult.new(Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED), [])
59
+ result.detail = Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED)
60
+ return result
47
61
  end
48
-
49
- # If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature
50
- # request events for prerequisites and we can skip allocating an array.
51
- if flag[:prerequisites] && !flag[:prerequisites].empty?
52
- events = []
53
- else
54
- events = nil
62
+
63
+ detail = eval_internal(flag, user, result, event_factory)
64
+ if !result.big_segments_status.nil?
65
+ # If big_segments_status is non-nil at the end of the evaluation, it means a query was done at
66
+ # some point and we will want to include the status in the evaluation reason.
67
+ detail = EvaluationDetail.new(detail.value, detail.variation_index,
68
+ detail.reason.with_big_segments_status(result.big_segments_status))
55
69
  end
70
+ result.detail = detail
71
+ return result
72
+ end
56
73
 
57
- detail = eval_internal(flag, user, events, event_factory)
58
- return EvalResult.new(detail, events.nil? || events.empty? ? nil : events)
74
+ def self.make_big_segment_ref(segment) # method is visible for testing
75
+ # The format of Big Segment references is independent of what store implementation is being
76
+ # used; the store implementation receives only this string and does not know the details of
77
+ # the data model. The Relay Proxy will use the same format when writing to the store.
78
+ "#{segment[:key]}.g#{segment[:generation]}"
59
79
  end
60
80
 
61
81
  private
62
82
 
63
- def eval_internal(flag, user, events, event_factory)
83
+ def eval_internal(flag, user, state, event_factory)
64
84
  if !flag[:on]
65
85
  return get_off_value(flag, EvaluationReason::off)
66
86
  end
67
87
 
68
- prereq_failure_reason = check_prerequisites(flag, user, events, event_factory)
88
+ prereq_failure_reason = check_prerequisites(flag, user, state, event_factory)
69
89
  if !prereq_failure_reason.nil?
70
90
  return get_off_value(flag, prereq_failure_reason)
71
91
  end
@@ -83,7 +103,7 @@ module LaunchDarkly
83
103
  rules = flag[:rules] || []
84
104
  rules.each_index do |i|
85
105
  rule = rules[i]
86
- if rule_match_user(rule, user)
106
+ if rule_match_user(rule, user, state)
87
107
  reason = rule[:_reason] # try to use cached reason for this rule
88
108
  reason = EvaluationReason::rule_match(i, rule[:id]) if reason.nil?
89
109
  return get_value_for_variation_or_rollout(flag, rule, user, reason)
@@ -98,7 +118,7 @@ module LaunchDarkly
98
118
  return EvaluationDetail.new(nil, nil, EvaluationReason::fallthrough)
99
119
  end
100
120
 
101
- def check_prerequisites(flag, user, events, event_factory)
121
+ def check_prerequisites(flag, user, state, event_factory)
102
122
  (flag[:prerequisites] || []).each do |prerequisite|
103
123
  prereq_ok = true
104
124
  prereq_key = prerequisite[:key]
@@ -109,14 +129,15 @@ module LaunchDarkly
109
129
  prereq_ok = false
110
130
  else
111
131
  begin
112
- prereq_res = eval_internal(prereq_flag, user, events, event_factory)
132
+ prereq_res = eval_internal(prereq_flag, user, state, event_factory)
113
133
  # Note that if the prerequisite flag is off, we don't consider it a match no matter what its
114
134
  # off variation was. But we still need to evaluate it in order to generate an event.
115
135
  if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation]
116
136
  prereq_ok = false
117
137
  end
118
138
  event = event_factory.new_eval_event(prereq_flag, user, prereq_res, nil, flag)
119
- events.push(event)
139
+ state.events = [] if state.events.nil?
140
+ state.events.push(event)
120
141
  rescue => exn
121
142
  Util.log_exception(@logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn)
122
143
  prereq_ok = false
@@ -130,23 +151,23 @@ module LaunchDarkly
130
151
  nil
131
152
  end
132
153
 
133
- def rule_match_user(rule, user)
154
+ def rule_match_user(rule, user, state)
134
155
  return false if !rule[:clauses]
135
156
 
136
157
  (rule[:clauses] || []).each do |clause|
137
- return false if !clause_match_user(clause, user)
158
+ return false if !clause_match_user(clause, user, state)
138
159
  end
139
160
 
140
161
  return true
141
162
  end
142
163
 
143
- def clause_match_user(clause, user)
164
+ def clause_match_user(clause, user, state)
144
165
  # In the case of a segment match operator, we check if the user is in any of the segments,
145
166
  # and possibly negate
146
167
  if clause[:op].to_sym == :segmentMatch
147
168
  result = (clause[:values] || []).any? { |v|
148
169
  segment = @get_segment.call(v)
149
- !segment.nil? && segment_match_user(segment, user)
170
+ !segment.nil? && segment_match_user(segment, user, state)
150
171
  }
151
172
  clause[:negate] ? !result : result
152
173
  else
@@ -168,11 +189,42 @@ module LaunchDarkly
168
189
  clause[:negate] ? !result : result
169
190
  end
170
191
 
171
- def segment_match_user(segment, user)
192
+ def segment_match_user(segment, user, state)
172
193
  return false unless user[:key]
194
+ segment[:unbounded] ? big_segment_match_user(segment, user, state) : simple_segment_match_user(segment, user, true)
195
+ end
173
196
 
174
- return true if segment[:included].include?(user[:key])
175
- return false if segment[:excluded].include?(user[:key])
197
+ def big_segment_match_user(segment, user, state)
198
+ if !segment[:generation]
199
+ # Big segment queries can only be done if the generation is known. If it's unset,
200
+ # that probably means the data store was populated by an older SDK that doesn't know
201
+ # about the generation property and therefore dropped it from the JSON data. We'll treat
202
+ # that as a "not configured" condition.
203
+ state.big_segments_status = BigSegmentsStatus::NOT_CONFIGURED
204
+ return false
205
+ end
206
+ if !state.big_segments_status
207
+ result = @get_big_segments_membership.nil? ? nil : @get_big_segments_membership.call(user[:key])
208
+ if result
209
+ state.big_segments_membership = result.membership
210
+ state.big_segments_status = result.status
211
+ else
212
+ state.big_segments_membership = nil
213
+ state.big_segments_status = BigSegmentsStatus::NOT_CONFIGURED
214
+ end
215
+ end
216
+ segment_ref = Evaluator.make_big_segment_ref(segment)
217
+ membership = state.big_segments_membership
218
+ included = membership.nil? ? nil : membership[segment_ref]
219
+ return included if !included.nil?
220
+ simple_segment_match_user(segment, user, false)
221
+ end
222
+
223
+ def simple_segment_match_user(segment, user, use_includes_and_excludes)
224
+ if use_includes_and_excludes
225
+ return true if segment[:included].include?(user[:key])
226
+ return false if segment[:excluded].include?(user[:key])
227
+ end
176
228
 
177
229
  (segment[:rules] || []).each do |r|
178
230
  return true if segment_rule_match_user(r, user, segment[:key], segment[:salt])
@@ -89,7 +89,7 @@ module LaunchDarkly
89
89
 
90
90
  private
91
91
 
92
- BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
92
+ BUILTINS = Set[:key, :secondary, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
93
93
  NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*")
94
94
 
95
95
  private_constant :BUILTINS
@@ -13,7 +13,7 @@ module LaunchDarkly
13
13
  end
14
14
 
15
15
  def new_eval_event(flag, user, detail, default_value, prereq_of_flag = nil)
16
- add_experiment_data = is_experiment(flag, detail.reason)
16
+ add_experiment_data = self.class.is_experiment(flag, detail.reason)
17
17
  e = {
18
18
  kind: 'feature',
19
19
  key: flag[:key],
@@ -91,17 +91,7 @@ module LaunchDarkly
91
91
  e
92
92
  end
93
93
 
94
- private
95
-
96
- def context_to_context_kind(user)
97
- if !user.nil? && user[:anonymous]
98
- return "anonymousUser"
99
- else
100
- return "user"
101
- end
102
- end
103
-
104
- def is_experiment(flag, reason)
94
+ def self.is_experiment(flag, reason)
105
95
  return false if !reason
106
96
 
107
97
  if reason.in_experiment
@@ -121,6 +111,13 @@ module LaunchDarkly
121
111
  false
122
112
  end
123
113
 
114
+ private def context_to_context_kind(user)
115
+ if !user.nil? && user[:anonymous]
116
+ return "anonymousUser"
117
+ else
118
+ return "user"
119
+ end
120
+ end
124
121
  end
125
122
  end
126
123
  end
@@ -4,10 +4,7 @@ module LaunchDarkly
4
4
  module Impl
5
5
  module Integrations
6
6
  module DynamoDB
7
- #
8
- # Internal implementation of the DynamoDB feature store, intended to be used with CachingStoreWrapper.
9
- #
10
- class DynamoDBFeatureStoreCore
7
+ class DynamoDBStoreImplBase
11
8
  begin
12
9
  require "aws-sdk-dynamodb"
13
10
  AWS_SDK_ENABLED = true
@@ -19,29 +16,50 @@ module LaunchDarkly
19
16
  AWS_SDK_ENABLED = false
20
17
  end
21
18
  end
22
-
19
+
23
20
  PARTITION_KEY = "namespace"
24
21
  SORT_KEY = "key"
25
22
 
26
- VERSION_ATTRIBUTE = "version"
27
- ITEM_JSON_ATTRIBUTE = "item"
28
-
29
23
  def initialize(table_name, opts)
30
24
  if !AWS_SDK_ENABLED
31
- raise RuntimeError.new("can't use DynamoDB feature store without the aws-sdk or aws-sdk-dynamodb gem")
25
+ raise RuntimeError.new("can't use #{description} without the aws-sdk or aws-sdk-dynamodb gem")
32
26
  end
33
-
27
+
34
28
  @table_name = table_name
35
- @prefix = opts[:prefix]
29
+ @prefix = opts[:prefix] ? (opts[:prefix] + ":") : ""
36
30
  @logger = opts[:logger] || Config.default_logger
37
-
31
+
38
32
  if !opts[:existing_client].nil?
39
33
  @client = opts[:existing_client]
40
34
  else
41
35
  @client = Aws::DynamoDB::Client.new(opts[:dynamodb_opts] || {})
42
36
  end
37
+
38
+ @logger.info("#{description}: using DynamoDB table \"#{table_name}\"")
39
+ end
40
+
41
+ def stop
42
+ # AWS client doesn't seem to have a close method
43
+ end
43
44
 
44
- @logger.info("DynamoDBFeatureStore: using DynamoDB table \"#{table_name}\"")
45
+ protected def description
46
+ "DynamoDB"
47
+ end
48
+ end
49
+
50
+ #
51
+ # Internal implementation of the DynamoDB feature store, intended to be used with CachingStoreWrapper.
52
+ #
53
+ class DynamoDBFeatureStoreCore < DynamoDBStoreImplBase
54
+ VERSION_ATTRIBUTE = "version"
55
+ ITEM_JSON_ATTRIBUTE = "item"
56
+
57
+ def initialize(table_name, opts)
58
+ super(table_name, opts)
59
+ end
60
+
61
+ def description
62
+ "DynamoDBFeatureStore"
45
63
  end
46
64
 
47
65
  def init_internal(all_data)
@@ -124,14 +142,10 @@ module LaunchDarkly
124
142
  !resp.item.nil? && resp.item.length > 0
125
143
  end
126
144
 
127
- def stop
128
- # AWS client doesn't seem to have a close method
129
- end
130
-
131
145
  private
132
146
 
133
147
  def prefixed_namespace(base_str)
134
- (@prefix.nil? || @prefix == "") ? base_str : "#{@prefix}:#{base_str}"
148
+ @prefix + base_str
135
149
  end
136
150
 
137
151
  def namespace_for_kind(kind)
@@ -208,6 +222,56 @@ module LaunchDarkly
208
222
  end
209
223
  end
210
224
 
225
+ class DynamoDBBigSegmentStore < DynamoDBStoreImplBase
226
+ KEY_METADATA = 'big_segments_metadata';
227
+ KEY_USER_DATA = 'big_segments_user';
228
+ ATTR_SYNC_TIME = 'synchronizedOn';
229
+ ATTR_INCLUDED = 'included';
230
+ ATTR_EXCLUDED = 'excluded';
231
+
232
+ def initialize(table_name, opts)
233
+ super(table_name, opts)
234
+ end
235
+
236
+ def description
237
+ "DynamoDBBigSegmentStore"
238
+ end
239
+
240
+ def get_metadata
241
+ key = @prefix + KEY_METADATA
242
+ data = @client.get_item(
243
+ table_name: @table_name,
244
+ key: {
245
+ PARTITION_KEY => key,
246
+ SORT_KEY => key
247
+ }
248
+ )
249
+ timestamp = data.item && data.item[ATTR_SYNC_TIME] ?
250
+ data.item[ATTR_SYNC_TIME] : nil
251
+ LaunchDarkly::Interfaces::BigSegmentStoreMetadata.new(timestamp)
252
+ end
253
+
254
+ def get_membership(user_hash)
255
+ data = @client.get_item(
256
+ table_name: @table_name,
257
+ key: {
258
+ PARTITION_KEY => @prefix + KEY_USER_DATA,
259
+ SORT_KEY => user_hash
260
+ })
261
+ return nil if !data.item
262
+ excluded_refs = data.item[ATTR_EXCLUDED] || []
263
+ included_refs = data.item[ATTR_INCLUDED] || []
264
+ if excluded_refs.empty? && included_refs.empty?
265
+ nil
266
+ else
267
+ membership = {}
268
+ excluded_refs.each { |ref| membership[ref] = false }
269
+ included_refs.each { |ref| membership[ref] = true }
270
+ membership
271
+ end
272
+ end
273
+ end
274
+
211
275
  class DynamoDBUtil
212
276
  #
213
277
  # Calls client.batch_write_item as many times as necessary to submit all of the given requests.