launchdarkly-server-sdk 6.2.5 → 6.3.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.