convert_sdk 1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The SDK's configuration surface and wire-translation boundary #1.
5
+ #
6
+ # +Config+ is the ONE place in the gem where the inbound public naming world
7
+ # (idiomatic snake_case keyword arguments) is translated into the internal /
8
+ # wire naming world (string-keyed camelCase). The only other naming-world
9
+ # conversion site in the entire gem is the outbound payload builder in
10
+ # ApiManager (Story 4.1); any other file converting option names is an
11
+ # architecture violation (the two-worlds rule).
12
+ #
13
+ # == JS-parity defaults
14
+ #
15
+ # {DEFAULTS} carries the frozen JS-parity values — batch 10, flush interval
16
+ # 1s, data-refresh 300s, bucketing seed 9999 / max-traffic 10000 / max-hash
17
+ # 2**32. These exact numbers are restated across the PRD, architecture, and
18
+ # research; they are NOT tuning knobs and must not drift. Endpoint URLs are
19
+ # copied verbatim from the reference SDKs' default config.
20
+ #
21
+ # == Fail-fast validation (the SDK's only raising surface)
22
+ #
23
+ # Construction delegates to {ConfigValidator}, which raises a stdlib
24
+ # +ArgumentError+ — naming the offending option and the expected type — for any
25
+ # presence/type violation. This is the SDK's ONLY raising surface (Decision 3):
26
+ # there is no custom exception hierarchy, because the SDK degrades gracefully
27
+ # (cached config / sentinels) for every runtime/infra failure, leaving nothing
28
+ # for a hierarchy to hold. Misconfiguration therefore surfaces immediately at
29
+ # boot rather than as silent misbehavior in production. Unknown option keys are
30
+ # rejected for the same reason — a typo fails fast instead of being ignored.
31
+ #
32
+ # == Nil-able timer intervals (Lambda / CLI mode)
33
+ #
34
+ # +flush_interval+ and +data_refresh_interval+ accept +nil+, meaning
35
+ # timer-off: the background flush / refresh threads are never started. This is
36
+ # the AWS Lambda and plain-CLI recipe (synchronous flush before exit; no
37
+ # background threads). +flush_interval+ is the canonical flush-timer key
38
+ # throughout the gem (the older +event_release_interval+ alias is retired).
39
+ #
40
+ # == Secret registration (NFR5)
41
+ #
42
+ # When an +sdk_key+ / +sdk_key_secret+ is present and a {LogManager} is
43
+ # injected, +Config+ registers those values with the manager's {Redactor}
44
+ # immediately at construction — so secrets are armed before any log line can
45
+ # carry them. +Config+ is constructible standalone (no +log_manager+) for unit
46
+ # tests; +ConvertSdk.create+ / Client (Story 2.5) injects the real manager.
47
+ class Config
48
+ # Number of milliseconds per second — the +flush_interval+ second→ms wire
49
+ # translation factor.
50
+ MILLISECONDS_PER_SECOND = 1000
51
+
52
+ # The fixed bucketing hash space (2**32). A JS-parity constant, not a
53
+ # configurable option — exposed as a reader for the bucketing engine.
54
+ MAX_HASH = 4_294_967_296
55
+
56
+ # The frozen JS-parity defaults for every public option. Verified against
57
+ # javascript-sdk +config/default.ts+ and php-sdk +DefaultConfig.php+.
58
+ DEFAULTS = {
59
+ # Auth / data — at least one of sdk_key / data is required (validated).
60
+ sdk_key: nil,
61
+ sdk_key_secret: nil,
62
+ data: nil,
63
+ # Platform environment selector; nil leaves it to the platform default.
64
+ environment: nil,
65
+ # Live Convert endpoints (verbatim from the reference default config).
66
+ config_endpoint: "https://cdn-4.convertexperiments.com/api/v1",
67
+ track_endpoint: "https://[project_id].metrics.convertexperiments.com/v1",
68
+ # Bucketing constants — frozen JS-parity numbers, do not tune.
69
+ max_traffic: 10_000,
70
+ hash_seed: 9999,
71
+ # Config-refresh cadence in seconds (JS 300000 ms); nil = timer-off.
72
+ data_refresh_interval: 300,
73
+ # Event delivery — batch size and flush cadence (seconds); nil = timer-off.
74
+ event_batch_size: 10,
75
+ flush_interval: 1,
76
+ # Rule evaluation options.
77
+ keys_case_sensitive: true,
78
+ negation: "!",
79
+ # Logging verbosity (JS default.ts:29 logLevel: LogLevel.DEBUG).
80
+ log_level: LogLevel::DEBUG,
81
+ # Network tracking enable/disable.
82
+ tracking: true,
83
+ # HTTP client timeouts in seconds (consumed by HttpClient, Story 1.5).
84
+ open_timeout: 5,
85
+ read_timeout: 10
86
+ }.freeze
87
+
88
+ # @!attribute [r] sdk_key
89
+ # @return [String, nil] account/project SDK key (JS +sdkKey+). Path auth.
90
+ # @!attribute [r] sdk_key_secret
91
+ # @return [String, nil] bearer secret (JS +sdkKeySecret+). Redacted.
92
+ # @!attribute [r] data
93
+ # @return [Hash, nil] inline config payload (JS +data+); skips fetch.
94
+ # @!attribute [r] environment
95
+ # @return [String, nil] platform environment (JS +environment+).
96
+ # @!attribute [r] config_endpoint
97
+ # @return [String] config-fetch base URL (JS +api.endpoint.config+).
98
+ # @!attribute [r] track_endpoint
99
+ # @return [String] tracking base URL (JS +api.endpoint.track+).
100
+ # @!attribute [r] max_traffic
101
+ # @return [Integer] bucketing max traffic (JS +bucketing.max_traffic+).
102
+ # @!attribute [r] hash_seed
103
+ # @return [Integer] bucketing hash seed (JS +bucketing.hash_seed+).
104
+ # @!attribute [r] data_refresh_interval
105
+ # @return [Numeric, nil] config-refresh seconds; nil = timer-off
106
+ # (JS +dataRefreshInterval+, ms).
107
+ # @!attribute [r] event_batch_size
108
+ # @return [Integer] event flush batch size (JS +events.batch_size+).
109
+ # @!attribute [r] flush_interval
110
+ # @return [Numeric, nil] flush cadence seconds; nil = timer-off
111
+ # (JS +events.release_interval+, ms). Canonical flush-timer key.
112
+ # @!attribute [r] keys_case_sensitive
113
+ # @return [Boolean] rule key case sensitivity (JS +rules.keys_case_sensitive+).
114
+ # @!attribute [r] negation
115
+ # @return [String] rule negation token (JS +rules.negation+, default "!").
116
+ # @!attribute [r] log_level
117
+ # @return [Integer] a {LogLevel} threshold (JS +logger.logLevel+).
118
+ # @!attribute [r] tracking
119
+ # @return [Boolean] network tracking enabled (JS +network.tracking+).
120
+ # @!attribute [r] open_timeout
121
+ # @return [Numeric] HTTP connect timeout seconds (HttpClient, NFR3).
122
+ # @!attribute [r] read_timeout
123
+ # @return [Numeric] HTTP read timeout seconds (HttpClient, NFR3).
124
+ attr_reader :sdk_key, :sdk_key_secret, :data, :environment,
125
+ :config_endpoint, :track_endpoint, :max_traffic, :hash_seed,
126
+ :data_refresh_interval, :event_batch_size, :flush_interval,
127
+ :keys_case_sensitive, :negation, :log_level, :tracking,
128
+ :open_timeout, :read_timeout
129
+
130
+ # Build a validated configuration from snake_case keyword options merged over
131
+ # {DEFAULTS}. Raises +ArgumentError+ (the SDK's only raising surface) on any
132
+ # presence/type violation or unknown option key.
133
+ #
134
+ # @param log_manager [LogManager, nil] when provided, present secrets
135
+ # (sdk_key / sdk_key_secret) are registered with its {Redactor} so they
136
+ # are armed before any log line. Optional for standalone construction.
137
+ # @param options [Hash{Symbol=>Object}] any subset of the {DEFAULTS} keys.
138
+ # @raise [ArgumentError] on unknown keys, missing sdk_key+data, or bad types.
139
+ def initialize(log_manager: nil, **options)
140
+ reject_unknown_keys(options)
141
+ merged = DEFAULTS.merge(options)
142
+ assign(merged)
143
+ ConfigValidator.new(merged).validate!
144
+ register_secrets(log_manager)
145
+ end
146
+
147
+ # @return [Integer] the fixed bucketing hash space (2**32).
148
+ def max_hash
149
+ MAX_HASH
150
+ end
151
+
152
+ # The wire-translation boundary: the internal, string-keyed camelCase
153
+ # representation downstream managers (DataManager / ApiManager) consume. This
154
+ # is the single inbound conversion site. +flush_interval+ seconds are
155
+ # translated to the millisecond wire value here; nil passes through (timer-off).
156
+ #
157
+ # @return [Hash{String=>Object}] a frozen internal config snapshot.
158
+ def to_internal
159
+ {
160
+ "sdkKey" => @sdk_key,
161
+ "sdkKeySecret" => @sdk_key_secret,
162
+ "data" => @data,
163
+ "environment" => @environment,
164
+ "configEndpoint" => @config_endpoint,
165
+ "trackEndpoint" => @track_endpoint,
166
+ "maxTraffic" => @max_traffic,
167
+ "hashSeed" => @hash_seed,
168
+ "maxHash" => MAX_HASH,
169
+ "dataRefreshInterval" => @data_refresh_interval,
170
+ "batchSize" => @event_batch_size,
171
+ "releaseInterval" => to_milliseconds(@flush_interval),
172
+ "keysCaseSensitive" => @keys_case_sensitive,
173
+ "negation" => @negation,
174
+ "logLevel" => @log_level,
175
+ "tracking" => @tracking
176
+ }.freeze
177
+ end
178
+
179
+ # The canonical set of accepted option keys (the {DEFAULTS} keys).
180
+ KNOWN_KEYS = DEFAULTS.keys.freeze
181
+
182
+ private
183
+
184
+ # Copy every merged option into its like-named instance variable.
185
+ def assign(merged)
186
+ merged.each { |key, value| instance_variable_set("@#{key}", value) }
187
+ end
188
+
189
+ # Reject any option key not in {KNOWN_KEYS} — a typo fails fast at boot.
190
+ def reject_unknown_keys(options)
191
+ unknown = options.keys - KNOWN_KEYS
192
+ return if unknown.empty?
193
+
194
+ raise ArgumentError, "unknown configuration option(s): #{unknown.join(", ")}"
195
+ end
196
+
197
+ # Translate a seconds interval to the millisecond wire value; nil passes
198
+ # through unchanged (timer-off).
199
+ def to_milliseconds(seconds)
200
+ return nil if seconds.nil?
201
+
202
+ seconds * MILLISECONDS_PER_SECOND
203
+ end
204
+
205
+ # Arm the Redactor with present secrets so no log line can leak them. A nil
206
+ # log_manager (standalone construction) is a no-op.
207
+ def register_secrets(log_manager)
208
+ return if log_manager.nil?
209
+
210
+ log_manager.register_secret(@sdk_key) unless @sdk_key.nil?
211
+ log_manager.register_secret(@sdk_key_secret) unless @sdk_key_secret.nil?
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The fail-fast validation rules for {Config} — the SDK's only raising surface.
5
+ #
6
+ # +ConfigValidator+ holds every presence/type rule for the configuration
7
+ # surface, extracted from {Config} so the surface class stays focused on
8
+ # naming-world translation and typed readers while the (uniform, table-like)
9
+ # validation rules live in one cohesive place. Every violation raises a stdlib
10
+ # +ArgumentError+ naming the offending option and the expected type; there is
11
+ # no custom exception hierarchy anywhere in the SDK (Decision 3).
12
+ #
13
+ # Rules:
14
+ #
15
+ # * presence — at least one of +sdk_key+ / +data+ must be present (FR6);
16
+ # * strings — +sdk_key+ / +sdk_key_secret+ / +environment+ / +config_endpoint+
17
+ # / +track_endpoint+ / +negation+ must be String (nil accepted for the
18
+ # optional ones); +data+ must be a Hash when present;
19
+ # * integers — +max_traffic+ / +hash_seed+ / +event_batch_size+;
20
+ # * intervals — +data_refresh_interval+ / +flush_interval+ are Numeric or nil
21
+ # (nil = timer-off); +open_timeout+ / +read_timeout+ are Numeric;
22
+ # * booleans — +keys_case_sensitive+ / +tracking+ (strict true/false);
23
+ # * log level — must be one of the {LogLevel} values.
24
+ class ConfigValidator
25
+ # The accepted {LogLevel} integer values (TRACE..SILENT).
26
+ LOG_LEVEL_VALUES = [
27
+ LogLevel::TRACE, LogLevel::DEBUG, LogLevel::INFO,
28
+ LogLevel::WARN, LogLevel::ERROR, LogLevel::SILENT
29
+ ].freeze
30
+
31
+ # @param values [Hash{Symbol=>Object}] the merged option values, keyed by the
32
+ # public snake_case option names (the {Config::DEFAULTS} keys).
33
+ def initialize(values)
34
+ @values = values
35
+ end
36
+
37
+ # Run every rule. The first violation raises; presence is checked first so a
38
+ # wholly-empty config reports the most useful fault.
39
+ #
40
+ # @raise [ArgumentError] on any presence/type violation.
41
+ # @return [void]
42
+ def validate!
43
+ validate_presence!
44
+ validate_strings!
45
+ validate_integers!
46
+ validate_intervals!
47
+ validate_booleans!
48
+ validate_log_level!
49
+ end
50
+
51
+ private
52
+
53
+ # At least one of sdk_key / data must be present (FR6).
54
+ def validate_presence!
55
+ return unless @values[:sdk_key].nil? && @values[:data].nil?
56
+
57
+ raise ArgumentError, "configuration requires sdk_key or data (at least one)"
58
+ end
59
+
60
+ # String-or-nil options, plus the Hash-or-nil data option.
61
+ def validate_strings!
62
+ %i[sdk_key sdk_key_secret environment config_endpoint track_endpoint negation].each do |name|
63
+ require_string(name, @values[name])
64
+ end
65
+ require_type(:data, @values[:data], Hash) unless @values[:data].nil?
66
+ end
67
+
68
+ # Integer options (bucketing constants and batch size).
69
+ def validate_integers!
70
+ %i[max_traffic hash_seed event_batch_size].each do |name|
71
+ require_type(name, @values[name], Integer)
72
+ end
73
+ end
74
+
75
+ # Timer intervals (Numeric or nil = timer-off) and HTTP timeouts (Numeric).
76
+ def validate_intervals!
77
+ require_interval(:data_refresh_interval, @values[:data_refresh_interval])
78
+ require_interval(:flush_interval, @values[:flush_interval])
79
+ require_type(:open_timeout, @values[:open_timeout], Numeric)
80
+ require_type(:read_timeout, @values[:read_timeout], Numeric)
81
+ end
82
+
83
+ # Strict boolean flags.
84
+ def validate_booleans!
85
+ %i[keys_case_sensitive tracking].each do |name|
86
+ require_boolean(name, @values[name])
87
+ end
88
+ end
89
+
90
+ # log_level must be one of the {LogLevel} values.
91
+ def validate_log_level!
92
+ level = @values[:log_level]
93
+ return if LOG_LEVEL_VALUES.include?(level)
94
+
95
+ raise ArgumentError,
96
+ "log_level must be a LogLevel value (#{LOG_LEVEL_VALUES.join(", ")}), got #{level.inspect}"
97
+ end
98
+
99
+ # Require String-or-nil (nil acceptable for optional strings).
100
+ def require_string(name, value)
101
+ return if value.nil? || value.is_a?(String)
102
+
103
+ raise ArgumentError, "#{name} must be a String, got #{value.class}"
104
+ end
105
+
106
+ # Require a Numeric-or-nil timer interval; nil means timer-off.
107
+ def require_interval(name, value)
108
+ return if value.nil? || value.is_a?(Numeric)
109
+
110
+ raise ArgumentError, "#{name} must be a Numeric (seconds) or nil (timer-off), got #{value.class}"
111
+ end
112
+
113
+ # Require +value+ to be an instance of +type+.
114
+ def require_type(name, value, type)
115
+ return if value.is_a?(type)
116
+
117
+ raise ArgumentError, "#{name} must be a #{type}, got #{value.class}"
118
+ end
119
+
120
+ # Require a strict boolean (true / false), never truthy coercion.
121
+ def require_boolean(name, value)
122
+ return if [true, false].include?(value)
123
+
124
+ raise ArgumentError, "#{name} must be a boolean (true/false), got #{value.class}"
125
+ end
126
+ end
127
+ end