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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- 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
|