launchdarkly-server-sdk 6.2.5 → 7.0.0

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -2
  3. data/lib/ldclient-rb/config.rb +203 -43
  4. data/lib/ldclient-rb/context.rb +487 -0
  5. data/lib/ldclient-rb/evaluation_detail.rb +85 -26
  6. data/lib/ldclient-rb/events.rb +185 -146
  7. data/lib/ldclient-rb/flags_state.rb +25 -14
  8. data/lib/ldclient-rb/impl/big_segments.rb +117 -0
  9. data/lib/ldclient-rb/impl/context.rb +96 -0
  10. data/lib/ldclient-rb/impl/context_filter.rb +145 -0
  11. data/lib/ldclient-rb/impl/diagnostic_events.rb +9 -10
  12. data/lib/ldclient-rb/impl/evaluator.rb +428 -132
  13. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +40 -41
  14. data/lib/ldclient-rb/impl/evaluator_helpers.rb +50 -0
  15. data/lib/ldclient-rb/impl/evaluator_operators.rb +26 -55
  16. data/lib/ldclient-rb/impl/event_sender.rb +6 -6
  17. data/lib/ldclient-rb/impl/event_summarizer.rb +68 -0
  18. data/lib/ldclient-rb/impl/event_types.rb +78 -0
  19. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +7 -7
  20. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +92 -28
  21. data/lib/ldclient-rb/impl/integrations/file_data_source.rb +212 -0
  22. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +165 -32
  23. data/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb +40 -0
  24. data/lib/ldclient-rb/impl/model/clause.rb +39 -0
  25. data/lib/ldclient-rb/impl/model/feature_flag.rb +213 -0
  26. data/lib/ldclient-rb/impl/model/preprocessed_data.rb +64 -0
  27. data/lib/ldclient-rb/impl/model/segment.rb +126 -0
  28. data/lib/ldclient-rb/impl/model/serialization.rb +54 -44
  29. data/lib/ldclient-rb/impl/repeating_task.rb +47 -0
  30. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +2 -2
  31. data/lib/ldclient-rb/impl/unbounded_pool.rb +1 -1
  32. data/lib/ldclient-rb/impl/util.rb +62 -1
  33. data/lib/ldclient-rb/in_memory_store.rb +2 -2
  34. data/lib/ldclient-rb/integrations/consul.rb +9 -2
  35. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -2
  36. data/lib/ldclient-rb/integrations/file_data.rb +108 -0
  37. data/lib/ldclient-rb/integrations/redis.rb +43 -3
  38. data/lib/ldclient-rb/integrations/test_data/flag_builder.rb +594 -0
  39. data/lib/ldclient-rb/integrations/test_data.rb +213 -0
  40. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +14 -9
  41. data/lib/ldclient-rb/integrations.rb +2 -51
  42. data/lib/ldclient-rb/interfaces.rb +151 -1
  43. data/lib/ldclient-rb/ldclient.rb +175 -133
  44. data/lib/ldclient-rb/memoized_value.rb +1 -1
  45. data/lib/ldclient-rb/non_blocking_thread_pool.rb +1 -1
  46. data/lib/ldclient-rb/polling.rb +22 -41
  47. data/lib/ldclient-rb/reference.rb +274 -0
  48. data/lib/ldclient-rb/requestor.rb +7 -7
  49. data/lib/ldclient-rb/stream.rb +9 -9
  50. data/lib/ldclient-rb/util.rb +11 -17
  51. data/lib/ldclient-rb/version.rb +1 -1
  52. data/lib/ldclient-rb.rb +2 -4
  53. metadata +49 -23
  54. data/lib/ldclient-rb/event_summarizer.rb +0 -55
  55. data/lib/ldclient-rb/file_data_source.rb +0 -314
  56. data/lib/ldclient-rb/impl/event_factory.rb +0 -126
  57. data/lib/ldclient-rb/newrelic.rb +0 -17
  58. data/lib/ldclient-rb/redis_store.rb +0 -88
  59. data/lib/ldclient-rb/user_filter.rb +0 -52
@@ -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
+ unless @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 unless @poll_worker.nil?
36
+ @store.stop unless @store.nil?
37
+ end
38
+
39
+ def get_context_membership(context_key)
40
+ return nil unless @store
41
+ membership = @cache[context_key]
42
+ unless membership
43
+ begin
44
+ membership = @store.get_membership(BigSegmentStoreManager.hash_for_context_key(context_key))
45
+ membership = EMPTY_MEMBERSHIP if membership.nil?
46
+ @cache[context_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 unless @last_status
53
+ unless @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
+ unless @store.nil?
66
+ begin
67
+ metadata = @store.get_metadata
68
+ new_status = Interfaces::BigSegmentStoreStatus.new(true, !metadata || 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 stale?(timestamp)
80
+ !timestamp || ((Impl::Util.current_time_millis - timestamp) >= @stale_after_millis)
81
+ end
82
+
83
+ def self.hash_for_context_key(context_key)
84
+ Digest::SHA256.base64digest(context_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
@@ -0,0 +1,96 @@
1
+ require "erb"
2
+
3
+ module LaunchDarkly
4
+ module Impl
5
+ module Context
6
+ ERR_KIND_NON_STRING = 'context kind must be a string'
7
+ ERR_KIND_CANNOT_BE_KIND = '"kind" is not a valid context kind'
8
+ ERR_KIND_CANNOT_BE_MULTI = '"multi" is not a valid context kind'
9
+ ERR_KIND_INVALID_CHARS = 'context kind contains disallowed characters'
10
+
11
+ ERR_KEY_NON_STRING = 'context key must be a string'
12
+ ERR_KEY_EMPTY = 'context key must not be empty'
13
+
14
+ ERR_NAME_NON_STRING = 'context name must be a string'
15
+
16
+ ERR_ANONYMOUS_NON_BOOLEAN = 'context anonymous must be a boolean'
17
+
18
+ #
19
+ # We allow consumers of this SDK to provide us with either a Hash or an
20
+ # instance of an LDContext. This is convenient for them but not as much
21
+ # for us. To make the conversion slightly more convenient for us, we have
22
+ # created this method.
23
+ #
24
+ # @param context [Hash, LDContext]
25
+ # @return [LDContext]
26
+ #
27
+ def self.make_context(context)
28
+ return context if context.is_a?(LDContext)
29
+
30
+ LDContext.create(context)
31
+ end
32
+
33
+ #
34
+ # Returns an error message if the kind is invalid; nil otherwise.
35
+ #
36
+ # @param kind [any]
37
+ # @return [String, nil]
38
+ #
39
+ def self.validate_kind(kind)
40
+ return ERR_KIND_NON_STRING unless kind.is_a?(String)
41
+ return ERR_KIND_CANNOT_BE_KIND if kind == "kind"
42
+ return ERR_KIND_CANNOT_BE_MULTI if kind == "multi"
43
+ return ERR_KIND_INVALID_CHARS unless kind.match?(/^[\w.-]+$/)
44
+ end
45
+
46
+ #
47
+ # Returns an error message if the key is invalid; nil otherwise.
48
+ #
49
+ # @param key [any]
50
+ # @return [String, nil]
51
+ #
52
+ def self.validate_key(key)
53
+ return ERR_KEY_NON_STRING unless key.is_a?(String)
54
+ return ERR_KEY_EMPTY if key == ""
55
+ end
56
+
57
+ #
58
+ # Returns an error message if the name is invalid; nil otherwise.
59
+ #
60
+ # @param name [any]
61
+ # @return [String, nil]
62
+ #
63
+ def self.validate_name(name)
64
+ return ERR_NAME_NON_STRING unless name.nil? || name.is_a?(String)
65
+ end
66
+
67
+ #
68
+ # Returns an error message if anonymous is invalid; nil otherwise.
69
+ #
70
+ # @param anonymous [any]
71
+ # @param allow_nil [Boolean]
72
+ # @return [String, nil]
73
+ #
74
+ def self.validate_anonymous(anonymous, allow_nil)
75
+ return nil if anonymous.nil? && allow_nil
76
+ return nil if [true, false].include? anonymous
77
+
78
+ ERR_ANONYMOUS_NON_BOOLEAN
79
+ end
80
+
81
+ #
82
+ # @param kind [String]
83
+ # @param key [String]
84
+ # @return [String]
85
+ #
86
+ def self.canonicalize_key_for_kind(kind, key)
87
+ # When building a FullyQualifiedKey, ':' and '%' are percent-escaped;
88
+ # we do not use a full URL-encoding function because implementations of
89
+ # this are inconsistent across platforms.
90
+ encoded = key.gsub("%", "%25").gsub(":", "%3A")
91
+
92
+ "#{kind}:#{encoded}"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,145 @@
1
+ module LaunchDarkly
2
+ module Impl
3
+ class ContextFilter
4
+ #
5
+ # @param all_attributes_private [Boolean]
6
+ # @param private_attributes [Array<String>]
7
+ #
8
+ def initialize(all_attributes_private, private_attributes)
9
+ @all_attributes_private = all_attributes_private
10
+
11
+ @private_attributes = []
12
+ private_attributes.each do |attribute|
13
+ reference = LaunchDarkly::Reference.create(attribute)
14
+ @private_attributes << reference if reference.error.nil?
15
+ end
16
+ end
17
+
18
+ #
19
+ # Return a hash representation of the provided context with attribute
20
+ # redaction applied.
21
+ #
22
+ # @param context [LaunchDarkly::LDContext]
23
+ # @return [Hash]
24
+ #
25
+ def filter(context)
26
+ return filter_single_context(context, true) unless context.multi_kind?
27
+
28
+ filtered = {kind: 'multi'}
29
+ (0...context.individual_context_count).each do |i|
30
+ c = context.individual_context(i)
31
+ next if c.nil?
32
+
33
+ filtered[c.kind] = filter_single_context(c, false)
34
+ end
35
+
36
+ filtered
37
+ end
38
+
39
+ #
40
+ # Apply redaction rules for a single context.
41
+ #
42
+ # @param context [LaunchDarkly::LDContext]
43
+ # @param include_kind [Boolean]
44
+ # @return [Hash]
45
+ #
46
+ private def filter_single_context(context, include_kind)
47
+ filtered = {key: context.key}
48
+
49
+ filtered[:kind] = context.kind if include_kind
50
+ filtered[:anonymous] = true if context.get_value(:anonymous)
51
+
52
+ redacted = []
53
+ private_attributes = @private_attributes.concat(context.private_attributes)
54
+
55
+ name = context.get_value(:name)
56
+ if !name.nil? && !check_whole_attribute_private(:name, private_attributes, redacted)
57
+ filtered[:name] = name
58
+ end
59
+
60
+ context.get_custom_attribute_names.each do |attribute|
61
+ unless check_whole_attribute_private(attribute, private_attributes, redacted)
62
+ value = context.get_value(attribute)
63
+ filtered[attribute] = redact_json_value(nil, attribute, value, private_attributes, redacted)
64
+ end
65
+ end
66
+
67
+ filtered[:_meta] = {redactedAttributes: redacted} unless redacted.empty?
68
+
69
+ filtered
70
+ end
71
+
72
+ #
73
+ # Check if an entire attribute should be redacted.
74
+ #
75
+ # @param attribute [Symbol]
76
+ # @param private_attributes [Array<Reference>]
77
+ # @param redacted [Array<Symbol>]
78
+ # @return [Boolean]
79
+ #
80
+ private def check_whole_attribute_private(attribute, private_attributes, redacted)
81
+ if @all_attributes_private
82
+ redacted << attribute
83
+ return true
84
+ end
85
+
86
+ private_attributes.each do |private_attribute|
87
+ if private_attribute.component(0) == attribute && private_attribute.depth == 1
88
+ redacted << attribute
89
+ return true
90
+ end
91
+ end
92
+
93
+ false
94
+ end
95
+
96
+ #
97
+ # Apply redaction rules to the provided value.
98
+ #
99
+ # @param parent_path [Array<String>, nil]
100
+ # @param name [String]
101
+ # @param value [any]
102
+ # @param private_attributes [Array<Reference>]
103
+ # @param redacted [Array<Symbol>]
104
+ # @return [any]
105
+ #
106
+ private def redact_json_value(parent_path, name, value, private_attributes, redacted)
107
+ return value unless value.is_a?(Hash)
108
+
109
+ ret = {}
110
+ current_path = parent_path.clone || []
111
+ current_path << name
112
+
113
+ value.each do |k, v|
114
+ was_redacted = false
115
+ private_attributes.each do |private_attribute|
116
+ next unless private_attribute.depth == (current_path.count + 1)
117
+
118
+ component = private_attribute.component(current_path.count)
119
+ next unless component == k
120
+
121
+ match = true
122
+ (0...current_path.count).each do |i|
123
+ unless private_attribute.component(i) == current_path[i]
124
+ match = false
125
+ break
126
+ end
127
+ end
128
+
129
+ if match
130
+ redacted << private_attribute.raw_path.to_sym
131
+ was_redacted = true
132
+ break
133
+ end
134
+ end
135
+
136
+ unless was_redacted
137
+ ret[k] = redact_json_value(current_path, k, v, private_attributes, redacted)
138
+ end
139
+ end
140
+
141
+ ret
142
+ end
143
+ end
144
+ end
145
+ end
@@ -9,7 +9,7 @@ module LaunchDarkly
9
9
  def self.create_diagnostic_id(sdk_key)
10
10
  {
11
11
  diagnosticId: SecureRandom.uuid,
12
- sdkKeySuffix: sdk_key[-6..-1] || sdk_key
12
+ sdkKeySuffix: sdk_key[-6..-1] || sdk_key,
13
13
  }
14
14
  end
15
15
 
@@ -25,16 +25,16 @@ module LaunchDarkly
25
25
  end
26
26
 
27
27
  def create_init_event(config)
28
- return {
28
+ {
29
29
  kind: 'diagnostic-init',
30
30
  creationDate: Util.current_time_millis,
31
31
  id: @id,
32
32
  configuration: DiagnosticAccumulator.make_config_data(config),
33
33
  sdk: DiagnosticAccumulator.make_sdk_data(config),
34
- platform: DiagnosticAccumulator.make_platform_data
34
+ platform: DiagnosticAccumulator.make_platform_data,
35
35
  }
36
36
  end
37
-
37
+
38
38
  def record_stream_init(timestamp, failed, duration_millis)
39
39
  @lock.synchronize do
40
40
  @stream_inits.push({ timestamp: timestamp, failed: failed, durationMillis: duration_millis })
@@ -57,7 +57,7 @@ module LaunchDarkly
57
57
  droppedEvents: dropped_events,
58
58
  deduplicatedUsers: deduplicated_users,
59
59
  eventsInLastBatch: events_in_last_batch,
60
- streamInits: previous_stream_inits
60
+ streamInits: previous_stream_inits,
61
61
  }
62
62
  @data_since_date = current_time
63
63
  event
@@ -73,12 +73,11 @@ module LaunchDarkly
73
73
  diagnosticRecordingIntervalMillis: self.seconds_to_millis(config.diagnostic_recording_interval),
74
74
  eventsCapacity: config.capacity,
75
75
  eventsFlushIntervalMillis: self.seconds_to_millis(config.flush_interval),
76
- inlineUsersInEvents: config.inline_users_in_events,
77
76
  pollingIntervalMillis: self.seconds_to_millis(config.poll_interval),
78
77
  socketTimeoutMillis: self.seconds_to_millis(config.read_timeout),
79
78
  streamingDisabled: !config.stream?,
80
- userKeysCapacity: config.user_keys_capacity,
81
- userKeysFlushIntervalMillis: self.seconds_to_millis(config.user_keys_flush_interval),
79
+ userKeysCapacity: config.context_keys_capacity,
80
+ userKeysFlushIntervalMillis: self.seconds_to_millis(config.context_keys_flush_interval),
82
81
  usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'),
83
82
  usingRelayDaemon: config.use_ldd?,
84
83
  }
@@ -88,7 +87,7 @@ module LaunchDarkly
88
87
  def self.make_sdk_data(config)
89
88
  ret = {
90
89
  name: 'ruby-server-sdk',
91
- version: LaunchDarkly::VERSION
90
+ version: LaunchDarkly::VERSION,
92
91
  }
93
92
  if config.wrapper_name
94
93
  ret[:wrapperName] = config.wrapper_name
@@ -105,7 +104,7 @@ module LaunchDarkly
105
104
  osName: self.normalize_os_name(conf['host_os']),
106
105
  osVersion: 'unknown', # there seems to be no portable way to detect this in Ruby
107
106
  rubyVersion: conf['ruby_version'],
108
- rubyImplementation: Object.constants.include?(:RUBY_ENGINE) ? RUBY_ENGINE : 'unknown'
107
+ rubyImplementation: Object.constants.include?(:RUBY_ENGINE) ? RUBY_ENGINE : 'unknown',
109
108
  }
110
109
  end
111
110