prefab-cloud-ruby 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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc.sample +3 -0
  3. data/.github/workflows/ruby.yml +46 -0
  4. data/.gitmodules +3 -0
  5. data/.rubocop.yml +13 -0
  6. data/.tool-versions +1 -0
  7. data/CHANGELOG.md +169 -0
  8. data/CODEOWNERS +1 -0
  9. data/Gemfile +26 -0
  10. data/Gemfile.lock +188 -0
  11. data/LICENSE.txt +20 -0
  12. data/README.md +94 -0
  13. data/Rakefile +50 -0
  14. data/VERSION +1 -0
  15. data/bin/console +21 -0
  16. data/compile_protos.sh +18 -0
  17. data/lib/prefab/client.rb +153 -0
  18. data/lib/prefab/config_client.rb +292 -0
  19. data/lib/prefab/config_client_presenter.rb +18 -0
  20. data/lib/prefab/config_loader.rb +84 -0
  21. data/lib/prefab/config_resolver.rb +77 -0
  22. data/lib/prefab/config_value_unwrapper.rb +115 -0
  23. data/lib/prefab/config_value_wrapper.rb +18 -0
  24. data/lib/prefab/context.rb +179 -0
  25. data/lib/prefab/context_shape.rb +20 -0
  26. data/lib/prefab/context_shape_aggregator.rb +65 -0
  27. data/lib/prefab/criteria_evaluator.rb +136 -0
  28. data/lib/prefab/encryption.rb +65 -0
  29. data/lib/prefab/error.rb +6 -0
  30. data/lib/prefab/errors/env_var_parse_error.rb +11 -0
  31. data/lib/prefab/errors/initialization_timeout_error.rb +13 -0
  32. data/lib/prefab/errors/invalid_api_key_error.rb +19 -0
  33. data/lib/prefab/errors/missing_default_error.rb +13 -0
  34. data/lib/prefab/errors/missing_env_var_error.rb +11 -0
  35. data/lib/prefab/errors/uninitialized_error.rb +13 -0
  36. data/lib/prefab/evaluation.rb +52 -0
  37. data/lib/prefab/evaluation_summary_aggregator.rb +87 -0
  38. data/lib/prefab/example_contexts_aggregator.rb +78 -0
  39. data/lib/prefab/exponential_backoff.rb +21 -0
  40. data/lib/prefab/feature_flag_client.rb +42 -0
  41. data/lib/prefab/http_connection.rb +41 -0
  42. data/lib/prefab/internal_logger.rb +16 -0
  43. data/lib/prefab/local_config_parser.rb +151 -0
  44. data/lib/prefab/log_path_aggregator.rb +69 -0
  45. data/lib/prefab/logger_client.rb +264 -0
  46. data/lib/prefab/murmer3.rb +50 -0
  47. data/lib/prefab/options.rb +208 -0
  48. data/lib/prefab/periodic_sync.rb +69 -0
  49. data/lib/prefab/prefab.rb +56 -0
  50. data/lib/prefab/rate_limit_cache.rb +41 -0
  51. data/lib/prefab/resolved_config_presenter.rb +86 -0
  52. data/lib/prefab/time_helpers.rb +7 -0
  53. data/lib/prefab/weighted_value_resolver.rb +42 -0
  54. data/lib/prefab/yaml_config_parser.rb +34 -0
  55. data/lib/prefab-cloud-ruby.rb +57 -0
  56. data/lib/prefab_pb.rb +93 -0
  57. data/prefab-cloud-ruby.gemspec +155 -0
  58. data/test/.prefab.default.config.yaml +2 -0
  59. data/test/.prefab.unit_tests.config.yaml +28 -0
  60. data/test/integration_test.rb +150 -0
  61. data/test/integration_test_helpers.rb +151 -0
  62. data/test/support/common_helpers.rb +180 -0
  63. data/test/support/mock_base_client.rb +42 -0
  64. data/test/support/mock_config_client.rb +19 -0
  65. data/test/support/mock_config_loader.rb +1 -0
  66. data/test/test_client.rb +444 -0
  67. data/test/test_config_client.rb +109 -0
  68. data/test/test_config_loader.rb +117 -0
  69. data/test/test_config_resolver.rb +430 -0
  70. data/test/test_config_value_unwrapper.rb +224 -0
  71. data/test/test_config_value_wrapper.rb +42 -0
  72. data/test/test_context.rb +203 -0
  73. data/test/test_context_shape.rb +50 -0
  74. data/test/test_context_shape_aggregator.rb +147 -0
  75. data/test/test_criteria_evaluator.rb +726 -0
  76. data/test/test_encryption.rb +16 -0
  77. data/test/test_evaluation_summary_aggregator.rb +162 -0
  78. data/test/test_example_contexts_aggregator.rb +238 -0
  79. data/test/test_exponential_backoff.rb +18 -0
  80. data/test/test_feature_flag_client.rb +48 -0
  81. data/test/test_helper.rb +17 -0
  82. data/test/test_integration.rb +58 -0
  83. data/test/test_local_config_parser.rb +147 -0
  84. data/test/test_log_path_aggregator.rb +62 -0
  85. data/test/test_logger.rb +621 -0
  86. data/test/test_logger_initialization.rb +12 -0
  87. data/test/test_options.rb +75 -0
  88. data/test/test_prefab.rb +12 -0
  89. data/test/test_rate_limit_cache.rb +44 -0
  90. data/test/test_weighted_value_resolver.rb +71 -0
  91. metadata +337 -0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ # Records the result of evaluating a config's criteria and forensics for reporting
5
+ class Evaluation
6
+ attr_reader :value
7
+
8
+ def initialize(config:, value:, value_index:, config_row_index:, context:, resolver:)
9
+ @config = config
10
+ @value = value
11
+ @value_index = value_index
12
+ @config_row_index = config_row_index
13
+ @context = context
14
+ @resolver = resolver
15
+ end
16
+
17
+ def unwrapped_value
18
+ deepest_value.unwrap
19
+ end
20
+
21
+ def reportable_value
22
+ deepest_value.reportable_value
23
+ end
24
+
25
+ def report_and_return(evaluation_summary_aggregator)
26
+ report(evaluation_summary_aggregator)
27
+
28
+ unwrapped_value
29
+ end
30
+
31
+ private
32
+
33
+ def report(evaluation_summary_aggregator)
34
+ return if @config.config_type == :LOG_LEVEL
35
+ evaluation_summary_aggregator&.record(
36
+ config_key: @config.key,
37
+ config_type: @config.config_type,
38
+ counter: {
39
+ config_id: @config.id,
40
+ config_row_index: @config_row_index,
41
+ conditional_value_index: @value_index,
42
+ selected_value: deepest_value.reportable_wrapped_value,
43
+ weighted_value_index: deepest_value.weighted_value_index,
44
+ selected_index: nil # TODO
45
+ })
46
+ end
47
+
48
+ def deepest_value
49
+ @deepest_value ||= Prefab::ConfigValueUnwrapper.deepest_value(@value, @config, @context, @resolver)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'periodic_sync'
4
+
5
+ module Prefab
6
+ # This class aggregates the number of times each config is evaluated, and
7
+ # details about how the config is evaluated This data is reported to the
8
+ # server at a regular interval defined by `sync_interval`.
9
+ class EvaluationSummaryAggregator
10
+ include Prefab::PeriodicSync
11
+
12
+ LOG = Prefab::InternalLogger.new(EvaluationSummaryAggregator)
13
+
14
+ attr_reader :data
15
+
16
+ def initialize(client:, max_keys:, sync_interval:)
17
+ @client = client
18
+ @max_keys = max_keys
19
+ @name = 'evaluation_summary_aggregator'
20
+
21
+ @data = Concurrent::Hash.new
22
+
23
+ start_periodic_sync(sync_interval)
24
+ end
25
+
26
+ def record(config_key:, config_type:, counter:)
27
+ return if @data.size >= @max_keys
28
+
29
+ key = [config_key, config_type]
30
+ @data[key] ||= Concurrent::Hash.new
31
+
32
+ @data[key][counter] ||= 0
33
+ @data[key][counter] += 1
34
+ end
35
+
36
+ private
37
+
38
+ def counter_proto(counter, count)
39
+ PrefabProto::ConfigEvaluationCounter.new(
40
+ config_id: counter[:config_id],
41
+ selected_index: counter[:selected_index],
42
+ config_row_index: counter[:config_row_index],
43
+ conditional_value_index: counter[:conditional_value_index],
44
+ weighted_value_index: counter[:weighted_value_index],
45
+ selected_value: counter[:selected_value],
46
+ count: count
47
+ )
48
+ end
49
+
50
+ def flush(to_ship, start_at_was)
51
+ pool.post do
52
+ LOG.debug "Flushing #{to_ship.size} summaries"
53
+
54
+ summaries_proto = PrefabProto::ConfigEvaluationSummaries.new(
55
+ start: start_at_was,
56
+ end: Prefab::TimeHelpers.now_in_ms,
57
+ summaries: summaries(to_ship)
58
+ )
59
+
60
+ result = post('/api/v1/telemetry', events(summaries_proto))
61
+
62
+ LOG.debug "Uploaded #{to_ship.size} summaries: #{result.status}"
63
+ end
64
+ end
65
+
66
+ def events(summaries)
67
+ event = PrefabProto::TelemetryEvent.new(summaries: summaries)
68
+
69
+ PrefabProto::TelemetryEvents.new(
70
+ instance_hash: @client.instance_hash,
71
+ events: [event]
72
+ )
73
+ end
74
+
75
+ def summaries(data)
76
+ data.map do |(config_key, config_type), counters|
77
+ counter_protos = counters.map { |counter, count| counter_proto(counter, count) }
78
+
79
+ PrefabProto::ConfigEvaluationSummary.new(
80
+ key: config_key,
81
+ type: config_type,
82
+ counters: counter_protos
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'periodic_sync'
4
+
5
+ module Prefab
6
+ # This class aggregates example contexts. It dedupes based on the
7
+ # concatenation of the keys of the contexts.
8
+ #
9
+ # It shouldn't send the same context more than once per hour.
10
+ class ExampleContextsAggregator
11
+ include Prefab::PeriodicSync
12
+
13
+ LOG = Prefab::InternalLogger.new(ExampleContextsAggregator)
14
+
15
+ attr_reader :data, :cache
16
+
17
+ ONE_HOUR = 60 * 60
18
+
19
+ def initialize(client:, max_contexts:, sync_interval:)
20
+ @client = client
21
+ @max_contexts = max_contexts
22
+ @name = 'example_contexts_aggregator'
23
+
24
+ @data = Concurrent::Array.new
25
+ @cache = Prefab::RateLimitCache.new(ONE_HOUR)
26
+
27
+ start_periodic_sync(sync_interval)
28
+ end
29
+
30
+ def record(contexts)
31
+ key = contexts.grouped_key
32
+
33
+ return unless @data.size < @max_contexts && !@cache.fresh?(key)
34
+
35
+ @cache.set(key)
36
+
37
+ @data.push(contexts)
38
+ end
39
+
40
+ private
41
+
42
+ def on_prepare_data
43
+ @cache.prune
44
+ end
45
+
46
+ def flush(to_ship, _)
47
+ pool.post do
48
+ LOG.debug "Flushing #{to_ship.size} examples"
49
+
50
+ result = post('/api/v1/telemetry', events(to_ship))
51
+
52
+ LOG.debug "Uploaded #{to_ship.size} examples: #{result.status}"
53
+ end
54
+ end
55
+
56
+ def example_contexts(to_ship)
57
+ to_ship.map do |contexts|
58
+ PrefabProto::ExampleContext.new(
59
+ timestamp: contexts.seen_at * 1000,
60
+ contextSet: contexts.slim_proto
61
+ )
62
+ end
63
+ end
64
+
65
+ def events(to_ship)
66
+ event = PrefabProto::TelemetryEvent.new(
67
+ example_contexts: PrefabProto::ExampleContexts.new(
68
+ examples: example_contexts(to_ship)
69
+ )
70
+ )
71
+
72
+ PrefabProto::TelemetryEvents.new(
73
+ instance_hash: @client.instance_hash,
74
+ events: [event]
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ # This class implements exponential backoff with a maximum delay.
5
+ #
6
+ # This is the default sync interval for aggregators.
7
+ class ExponentialBackoff
8
+ def initialize(max_delay:, initial_delay: 2, multiplier: 2)
9
+ @initial_delay = initial_delay
10
+ @max_delay = max_delay
11
+ @multiplier = multiplier
12
+ @delay = initial_delay
13
+ end
14
+
15
+ def call
16
+ delay = @delay
17
+ @delay = [@delay * @multiplier, @max_delay].min
18
+ delay
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ class FeatureFlagClient
5
+ def initialize(base_client)
6
+ @base_client = base_client
7
+ end
8
+
9
+ def feature_is_on?(feature_name)
10
+ feature_is_on_for?(feature_name, {})
11
+ end
12
+
13
+ def feature_is_on_for?(feature_name, properties)
14
+ variant = @base_client.config_client.get(feature_name, false, properties)
15
+
16
+ is_on?(variant)
17
+ end
18
+
19
+ def get(feature_name, properties, default: false)
20
+ value = _get(feature_name, properties)
21
+
22
+ value.nil? ? default : value
23
+ end
24
+
25
+ private
26
+
27
+ def _get(feature_name, properties)
28
+ @base_client.config_client.get(feature_name, nil, properties)
29
+ end
30
+
31
+ def is_on?(variant)
32
+ return false if variant.nil?
33
+
34
+ return variant if variant == !!variant
35
+
36
+ variant.bool
37
+ rescue StandardError
38
+ @base_client.log.info("is_on? methods only work for boolean feature flags variants. This feature flags variant is '#{variant}'. Returning false")
39
+ false
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ class HttpConnection
5
+ AUTH_USER = 'authuser'
6
+ PROTO_HEADERS = {
7
+ 'Content-Type' => 'application/x-protobuf',
8
+ 'Accept' => 'application/x-protobuf',
9
+ 'X-PrefabCloud-Client-Version' => "prefab-cloud-ruby-#{Prefab::VERSION}"
10
+ }.freeze
11
+
12
+ def initialize(api_root, api_key)
13
+ @api_root = api_root
14
+ @api_key = api_key
15
+ end
16
+
17
+ def get(path)
18
+ connection(PROTO_HEADERS).get(path)
19
+ end
20
+
21
+ def post(path, body)
22
+ connection(PROTO_HEADERS).post(path, body.to_proto)
23
+ end
24
+
25
+ def connection(headers = {})
26
+ if Faraday::VERSION[0].to_i >= 2
27
+ Faraday.new(@api_root) do |conn|
28
+ conn.request :authorization, :basic, AUTH_USER, @api_key
29
+
30
+ conn.headers.merge!(headers)
31
+ end
32
+ else
33
+ Faraday.new(@api_root) do |conn|
34
+ conn.request :basic_auth, AUTH_USER, @api_key
35
+
36
+ conn.headers.merge!(headers)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ class InternalLogger < StaticLogger
5
+ INTERNAL_PREFIX = 'cloud.prefab.client'
6
+
7
+ def initialize(path)
8
+ if path.is_a?(Class)
9
+ path_string = path.name.split('::').last.downcase
10
+ else
11
+ path_string = path
12
+ end
13
+ super("#{INTERNAL_PREFIX}.#{path_string}")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prefab
4
+ class LocalConfigParser
5
+ class << self
6
+ def parse(key, value, config, file)
7
+ if value.instance_of?(Hash)
8
+ if value['feature_flag']
9
+ config[key] = feature_flag_config(file, key, value)
10
+ elsif value['type'] == 'provided'
11
+ config[key] = provided_config(file, key, value)
12
+ elsif value['decrypt_with'] || value['confidential']
13
+ config[key] = complex_string(file, key, value)
14
+ else
15
+ value.each do |nest_key, nest_value|
16
+ nested_key = "#{key}.#{nest_key}"
17
+ nested_key = key if nest_key == '_'
18
+ parse(nested_key, nest_value, config, file)
19
+ end
20
+ end
21
+ else
22
+ config[key] = {
23
+ source: file,
24
+ match: 'default',
25
+ config: PrefabProto::Config.new(
26
+ config_type: :CONFIG,
27
+ key: key,
28
+ rows: [
29
+ PrefabProto::ConfigRow.new(values: [
30
+ PrefabProto::ConditionalValue.new(value: value_from(key, value))
31
+ ])
32
+ ]
33
+ )
34
+ }
35
+ end
36
+
37
+ config
38
+ end
39
+
40
+ def value_from(key, raw)
41
+ case raw
42
+ when String
43
+ if key.to_s.start_with? Prefab::LoggerClient::BASE_KEY
44
+ prefab_log_level_resolve = PrefabProto::LogLevel.resolve(raw.upcase.to_sym) || PrefabProto::LogLevel::NOT_SET_LOG_LEVEL
45
+ { log_level: prefab_log_level_resolve }
46
+ else
47
+ { string: raw }
48
+ end
49
+ when Integer
50
+ { int: raw }
51
+ when TrueClass, FalseClass
52
+ { bool: raw }
53
+ when Float
54
+ { double: raw }
55
+ end
56
+ end
57
+
58
+ def feature_flag_config(file, key, value)
59
+ criterion = (parse_criterion(value['criterion']) if value['criterion'])
60
+
61
+ variant = PrefabProto::ConfigValue.new(value_from(key, value['value']))
62
+
63
+ row = PrefabProto::ConfigRow.new(
64
+ values: [
65
+ PrefabProto::ConditionalValue.new(
66
+ criteria: [criterion].compact,
67
+ value: variant
68
+ )
69
+ ]
70
+ )
71
+
72
+ raise Prefab::Error, "Feature flag config `#{key}` #{file} must have a `value`" unless value.key?('value')
73
+
74
+ {
75
+ source: file,
76
+ match: key,
77
+ config: PrefabProto::Config.new(
78
+ config_type: :FEATURE_FLAG,
79
+ key: key,
80
+ allowable_values: [variant],
81
+ rows: [row]
82
+ )
83
+ }
84
+ end
85
+
86
+ def provided_config(file, key, value_hash)
87
+ value = PrefabProto::ConfigValue.new(provided: PrefabProto::Provided.new(
88
+ source: :ENV_VAR,
89
+ lookup: value_hash["lookup"],
90
+ ),
91
+ confidential: value_hash["confidential"],
92
+ )
93
+
94
+ row = PrefabProto::ConfigRow.new(
95
+ values: [
96
+ PrefabProto::ConditionalValue.new(
97
+ value: value
98
+ )
99
+ ]
100
+ )
101
+
102
+ {
103
+ source: file,
104
+ match: value.provided.lookup,
105
+ config: PrefabProto::Config.new(
106
+ config_type: :CONFIG,
107
+ key: key,
108
+ rows: [row]
109
+ )
110
+ }
111
+ end
112
+
113
+ def complex_string(file, key, value_hash)
114
+ value = PrefabProto::ConfigValue.new(
115
+ string: value_hash["value"],
116
+ confidential: value_hash["confidential"],
117
+ decrypt_with: value_hash["decrypt_with"],
118
+ )
119
+
120
+ row = PrefabProto::ConfigRow.new(
121
+ values: [
122
+ PrefabProto::ConditionalValue.new(
123
+ value: value
124
+ )
125
+ ]
126
+ )
127
+
128
+ {
129
+ source: file,
130
+ config: PrefabProto::Config.new(
131
+ config_type: :CONFIG,
132
+ key: key,
133
+ rows: [row]
134
+ )
135
+ }
136
+ end
137
+
138
+ def parse_criterion(criterion)
139
+ PrefabProto::Criterion.new(operator: criterion['operator'],
140
+ property_name: criterion['property'],
141
+ value_to_match: parse_value_to_match(criterion['values']))
142
+ end
143
+
144
+ def parse_value_to_match(values)
145
+ raise "Can't handle #{values}" unless values.instance_of?(Array)
146
+
147
+ PrefabProto::ConfigValue.new(string_list: PrefabProto::StringList.new(values: values))
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'periodic_sync'
4
+
5
+ module Prefab
6
+ class LogPathAggregator
7
+ LOG = Prefab::InternalLogger.new(LogPathAggregator)
8
+
9
+ include Prefab::PeriodicSync
10
+
11
+ INCREMENT = ->(count) { (count || 0) + 1 }
12
+
13
+ SEVERITY_KEY = {
14
+ ::Logger::DEBUG => 'debugs',
15
+ ::Logger::INFO => 'infos',
16
+ ::Logger::WARN => 'warns',
17
+ ::Logger::ERROR => 'errors',
18
+ ::Logger::FATAL => 'fatals'
19
+ }.freeze
20
+
21
+ attr_reader :data
22
+
23
+ def initialize(client:, max_paths:, sync_interval:)
24
+ @max_paths = max_paths
25
+ @client = client
26
+ @name = 'log_path_aggregator'
27
+
28
+ @data = Concurrent::Map.new
29
+
30
+ @last_data_sent = nil
31
+ @last_request = nil
32
+
33
+ start_periodic_sync(sync_interval)
34
+ end
35
+
36
+ def push(path, severity)
37
+ return if @data.size >= @max_paths
38
+
39
+ @data.compute([path, severity], &INCREMENT)
40
+ end
41
+
42
+ private
43
+
44
+ def flush(to_ship, start_at_was)
45
+ pool.post do
46
+ LOG.debug "Uploading stats for #{to_ship.size} paths"
47
+
48
+ aggregate = Hash.new { |h, k| h[k] = PrefabProto::Logger.new }
49
+
50
+ to_ship.each do |(path, severity), count|
51
+ aggregate[path][SEVERITY_KEY[severity]] = count
52
+ aggregate[path]['logger_name'] = path
53
+ end
54
+
55
+ loggers = PrefabProto::Loggers.new(
56
+ loggers: aggregate.values,
57
+ start_at: start_at_was,
58
+ end_at: Prefab::TimeHelpers.now_in_ms,
59
+ instance_hash: @client.instance_hash,
60
+ namespace: @client.namespace
61
+ )
62
+
63
+ result = post('/api/v1/known-loggers', loggers)
64
+
65
+ LOG.debug "Uploaded #{to_ship.size} paths: #{result.status}"
66
+ end
67
+ end
68
+ end
69
+ end