convert_sdk 1.0.0 → 2.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 +4 -4
- data/.rubocop.yml +9 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +1 -1
- data/lib/convert_sdk/api_manager.rb +167 -0
- data/lib/convert_sdk/bucketing_manager.rb +161 -0
- data/lib/convert_sdk/client.rb +42 -7
- data/lib/convert_sdk/config.rb +20 -2
- data/lib/convert_sdk/config_validator.rb +18 -2
- data/lib/convert_sdk/context.rb +267 -15
- data/lib/convert_sdk/data_manager.rb +193 -38
- data/lib/convert_sdk/redactor.rb +6 -4
- data/lib/convert_sdk/rule_manager.rb +70 -11
- data/lib/convert_sdk/segments_manager.rb +34 -10
- data/lib/convert_sdk/version.rb +1 -1
- data/lib/convert_sdk.rb +42 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4971b19e554964f05a8c86555b919d102492a09190eac1747567177f375dc29e
|
|
4
|
+
data.tar.gz: b4a91c4ab52075f0268aa1f138989f406d4f9166f7cd06f859ea879a4f7e2975
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 288df65beff1f18b3dbe25334e31e852222705c0786113cb56fbd3a253ffc5edcfba335a00258c062ea7faf34728afd5e26ecd234f7df305cb74e043275e962b
|
|
7
|
+
data.tar.gz: 2ee04c45261064677c2cd2da5bd4a89e06747f39cf172a9d02a87a976ef167d48b7ddafaa5df3b1c1ad9961e5e7fced4d636313f9645dd5ac2b0bf670f9c748e
|
data/.rubocop.yml
CHANGED
|
@@ -82,6 +82,15 @@ Metrics/ClassLength:
|
|
|
82
82
|
# length reflects the ported reference surface, not unmanaged growth (same
|
|
83
83
|
# rationale as the DataManager/RuleManager feature surfaces above).
|
|
84
84
|
- "lib/convert_sdk/feature_manager.rb"
|
|
85
|
+
# ApiManager (RB-3 / qs-03 AC4/AC8) gains #get_config_by_experience — the
|
|
86
|
+
# exp-scoped preview-config fetch + its process-wide memo. The task spec
|
|
87
|
+
# explicitly directs this onto ApiManager rather than a new file ("do not
|
|
88
|
+
# reach into Client or duplicate the qs-02 low-cache pattern wholesale —
|
|
89
|
+
# this is a distinct exp-scoped endpoint" living beside the existing
|
|
90
|
+
# HTTP-port delivery surface). Same rationale as the other cohesive-
|
|
91
|
+
# feature-surface exclusions above: the growth is the mandated surface,
|
|
92
|
+
# not unmanaged sprawl.
|
|
93
|
+
- "lib/convert_sdk/api_manager.rb"
|
|
85
94
|
|
|
86
95
|
# The Client (Story 2.5) is wired by explicit constructor injection of its six
|
|
87
96
|
# collaborator managers (config, log, http, data-store, event, data) — the
|
data/CONTRIBUTING.md
CHANGED
|
@@ -73,7 +73,7 @@ runs on CRuby only — the JRuby leg runs the suite without the gate.
|
|
|
73
73
|
|-----------|------------------|
|
|
74
74
|
| `spec/unit/` | Per-class unit specs (mock collaborators, isolated logic). |
|
|
75
75
|
| `spec/integration/` | End-to-end specs: `full_chain_spec.rb` (the release-blocking create→decide→track→flush loop), `runtime_recipes_spec.rb` (the runtime-lifecycle recipes the quickstarts are transcribed from), `fork_safety_spec.rb`, `factory_wiring_spec.rb`. |
|
|
76
|
-
| `spec/cross_sdk/` | The cross-SDK MurmurHash3
|
|
76
|
+
| `spec/cross_sdk/` | The cross-SDK parity proofs: MurmurHash3 hash vectors and the anchored bucketing layout vectors (`anchored_bucketing_vectors_spec.rb`) — the byte-identical bucketing proof. |
|
|
77
77
|
| `spec/docs/` | Docs-snippet smoke specs — run the README/quickstart code samples against the real gem so documentation never drifts. |
|
|
78
78
|
| `spec/staging/` | The live-platform suite (runs on schedule/dispatch only, never in PR CI). |
|
|
79
79
|
| `spec/support/` | Shared helpers (e.g. `runtime_recipe_helpers.rb`, which co-locates the recipe wiring snippets the quickstarts ship). |
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Convert Ruby SDK
|
|
2
2
|
|
|
3
3
|
[](https://github.com/convertcom/ruby-sdk/actions/workflows/qa.yml)
|
|
4
|
-
[](https://rubygems.org/gems/convert_sdk)
|
|
5
5
|
[](https://convertcom.github.io/ruby-sdk)
|
|
6
6
|
|
|
7
7
|
The official Convert Experiences FullStack Ruby SDK — server-side A/B testing,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
5
7
|
module ConvertSdk
|
|
6
8
|
# The outbound delivery manager — it owns the {VisitorsQueue}, the tracking
|
|
7
9
|
# endpoint, queue release, and THE wire-payload builder.
|
|
@@ -59,6 +61,97 @@ module ConvertSdk
|
|
|
59
61
|
# dataStore is configured, and Ruby always provides one (api-manager.ts:94).
|
|
60
62
|
ENRICH_DATA = false
|
|
61
63
|
|
|
64
|
+
# qs-03 AC8 — the {#get_config_by_experience} memo TTL in seconds. JS
|
|
65
|
+
# oracle: +CONFIG_BY_EXPERIENCE_TTL+ (api-manager.ts, ms there, seconds
|
|
66
|
+
# here — Ruby-side math is wall-clock seconds via +Time.now.to_f+).
|
|
67
|
+
CONFIG_BY_EXPERIENCE_TTL = 60
|
|
68
|
+
|
|
69
|
+
# qs-03 AC8 — the PROCESS-WIDE {#get_config_by_experience} memo: a class-
|
|
70
|
+
# level Hash (NOT a class variable — see architecture's "no globals or
|
|
71
|
+
# singletons" constructor-injection mandate; this is the one deliberate,
|
|
72
|
+
# narrowly-scoped exception, mirroring the JS oracle's module-level
|
|
73
|
+
# +configByExperienceCache+ Map so every {ApiManager} instance sharing an
|
|
74
|
+
# +sdk_key+ shares one fetch) keyed +"{sdk_key}:{experience_id}"+, entries
|
|
75
|
+
# shaped +{"config" => Hash, "expires_at" => Float}+. Mutated only under
|
|
76
|
+
# the +@config_by_experience_mutex+ below (fork-safety + thread-safety) —
|
|
77
|
+
# mirrors {ForkGuard}'s own module-level-state + mutex discipline
|
|
78
|
+
# (fork_guard.rb).
|
|
79
|
+
@config_by_experience_cache = {}
|
|
80
|
+
# qs-03 AC8 — guards {@config_by_experience_cache}. Class-level (not
|
|
81
|
+
# per-instance) because the cache it protects is process-wide.
|
|
82
|
+
@config_by_experience_mutex = Thread::Mutex.new
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
# Read a still-fresh (within {CONFIG_BY_EXPERIENCE_TTL}) memoized config
|
|
86
|
+
# for +cache_key+, or +nil+ when absent/expired (qs-03 AC8).
|
|
87
|
+
# @api private
|
|
88
|
+
# @param cache_key [String]
|
|
89
|
+
# @return [Hash{String=>Object}, nil]
|
|
90
|
+
def cached_config_by_experience(cache_key)
|
|
91
|
+
@config_by_experience_mutex.synchronize do
|
|
92
|
+
entry = @config_by_experience_cache[cache_key]
|
|
93
|
+
entry["config"] if entry && Time.now.to_f < entry["expires_at"]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Memoize +config+ under +cache_key+ for {CONFIG_BY_EXPERIENCE_TTL}
|
|
98
|
+
# seconds (qs-03 AC8). A failed fetch must NEVER reach this method (the
|
|
99
|
+
# caller only memoizes on success).
|
|
100
|
+
#
|
|
101
|
+
# FIX-1 (code review round 1) — sweeps every OTHER already-expired entry
|
|
102
|
+
# from the process-wide cache before inserting, mirroring the JS oracle
|
|
103
|
+
# (+api-manager.ts+ ~367-370, which does the same on every
|
|
104
|
+
# +getConfigByExperience+ write). +experience_id+ is
|
|
105
|
+
# operator/attacker-influenced (the +convert_preview={expId}.{varId}+
|
|
106
|
+
# link param), so without this sweep a stream of distinct ids would grow
|
|
107
|
+
# this Hash without bound in a long-lived process (a Puma/Rails worker) —
|
|
108
|
+
# it would otherwise only shrink on fork or process exit.
|
|
109
|
+
# @api private
|
|
110
|
+
# @param cache_key [String]
|
|
111
|
+
# @param config [Hash{String=>Object}]
|
|
112
|
+
# @return [void]
|
|
113
|
+
def memoize_config_by_experience(cache_key, config)
|
|
114
|
+
@config_by_experience_mutex.synchronize do
|
|
115
|
+
now = Time.now.to_f
|
|
116
|
+
@config_by_experience_cache.delete_if { |_key, entry| entry["expires_at"] <= now }
|
|
117
|
+
@config_by_experience_cache[cache_key] = {
|
|
118
|
+
"config" => config,
|
|
119
|
+
"expires_at" => now + CONFIG_BY_EXPERIENCE_TTL
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Test-only reset (mirrors {ForkGuard.reset_for_tests!}) so specs stay
|
|
125
|
+
# order-independent against this process-wide memo. Also the FORK-side
|
|
126
|
+
# clear (registered per-instance in {#initialize} via
|
|
127
|
+
# +ForkGuard.register_child_callback+): even though a stale memo isn't
|
|
128
|
+
# unsafe to serve, clearing it on fork matches this file's existing
|
|
129
|
+
# queue-ownership-clear discipline ({#clear_queue_ownership}) and avoids
|
|
130
|
+
# a forked child transparently serving a parent process's stale
|
|
131
|
+
# preview-experience config across a fork boundary.
|
|
132
|
+
# @api private
|
|
133
|
+
# @return [void]
|
|
134
|
+
def reset_config_by_experience_cache_for_tests!
|
|
135
|
+
@config_by_experience_mutex.synchronize { @config_by_experience_cache = {} }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Test-only inspection: the current size of {@config_by_experience_cache}
|
|
139
|
+
# (FIX-1, code review round 1, qs-03 AC8 hardening). Lets specs assert
|
|
140
|
+
# that {.memoize_config_by_experience}'s expired-entry sweep actually
|
|
141
|
+
# shrinks the process-wide Hash — not merely that an expired LOOKUP
|
|
142
|
+
# returns nil. `experience_id` is operator/attacker-influenced (the
|
|
143
|
+
# +convert_preview={expId}.{varId}+ link param), so without a sweep a
|
|
144
|
+
# stream of distinct ids would accumulate unbounded entries in a
|
|
145
|
+
# long-lived process (Puma/Rails worker). Mirrors
|
|
146
|
+
# {.reset_config_by_experience_cache_for_tests!}'s test-only scope and
|
|
147
|
+
# mutex discipline.
|
|
148
|
+
# @api private
|
|
149
|
+
# @return [Integer]
|
|
150
|
+
def config_by_experience_cache_size_for_tests
|
|
151
|
+
@config_by_experience_mutex.synchronize { @config_by_experience_cache.size }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
62
155
|
# @param config [Config] the validated configuration (track endpoint, sdk_key,
|
|
63
156
|
# sdk_key_secret).
|
|
64
157
|
# @param data_manager [DataManager] supplies +account_id+ / +project_id+ for
|
|
@@ -95,6 +188,12 @@ module ConvertSdk
|
|
|
95
188
|
# knows nothing about the queue; ApiManager owns its own clear (architecture
|
|
96
189
|
# Decision 6 callback-registry design).
|
|
97
190
|
ForkGuard.register_child_callback(-> { clear_queue_ownership })
|
|
191
|
+
# qs-03 AC8 — clear the process-wide config-by-experience memo in a
|
|
192
|
+
# forked child (see {ApiManager.reset_config_by_experience_cache_for_tests!}
|
|
193
|
+
# doc for the rationale). Registered ALONGSIDE (not replacing) the
|
|
194
|
+
# queue-ownership-clear callback above — ForkGuard fires every
|
|
195
|
+
# registered callback in registration order.
|
|
196
|
+
ForkGuard.register_child_callback(-> { ApiManager.reset_config_by_experience_cache_for_tests! })
|
|
98
197
|
end
|
|
99
198
|
|
|
100
199
|
# @return [VisitorsQueue] the underlying per-visitor event queue.
|
|
@@ -167,6 +266,42 @@ module ConvertSdk
|
|
|
167
266
|
@log_manager.error("ApiManager#release_queue: #{e.class}: #{e.message}")
|
|
168
267
|
end
|
|
169
268
|
|
|
269
|
+
# Fetch (or reuse a memoized) config scoped to a single experience — qs-03
|
|
270
|
+
# AC4 fetch resolution / AC8 memoization, the preview lookup path. Always
|
|
271
|
+
# requests +_conv_low_cache=1+ (bypassing the CDN cache) and scopes the
|
|
272
|
+
# response with +exp=<experience_id>+; includes +environment+ /
|
|
273
|
+
# +debug_token+ when configured. JS oracle: +getConfigByExperience+
|
|
274
|
+
# (api-manager.ts ~344).
|
|
275
|
+
#
|
|
276
|
+
# Memoized PROCESS-WIDE (see {CONFIG_BY_EXPERIENCE_TTL}), keyed
|
|
277
|
+
# +"{sdk_key}:{experience_id}"+ — repeated lookups within the TTL window,
|
|
278
|
+
# including from a DIFFERENT {ApiManager} instance sharing the same
|
|
279
|
+
# +sdk_key+, reuse the same fetch. Never touches the store (qs-03: "TTL
|
|
280
|
+
# 60s (in-memory only; never the store)") — the memo lives entirely in the
|
|
281
|
+
# class-level cache.
|
|
282
|
+
#
|
|
283
|
+
# On a failed fetch (transport failure, non-2xx, or a non-Hash body):
|
|
284
|
+
# returns +nil+ WITHOUT memoizing, so the next lookup retries (graceful
|
|
285
|
+
# degradation, NFR9 — never raises).
|
|
286
|
+
#
|
|
287
|
+
# @param experience_id [String]
|
|
288
|
+
# @return [Hash{String=>Object}, nil] the fetched/memoized config, or nil
|
|
289
|
+
# on a failed fetch.
|
|
290
|
+
def get_config_by_experience(experience_id)
|
|
291
|
+
# Data-only mode guard (review round 2) — a direct-data Config has no
|
|
292
|
+
# sdk_key, and therefore no config-fetch endpoint to hit; without this
|
|
293
|
+
# guard a preview resolution reachable in that mode would build a
|
|
294
|
+
# +.../config/?exp=…+ URL with a nil key and fire a guaranteed-failing
|
|
295
|
+
# HTTP request.
|
|
296
|
+
return nil if @config.sdk_key.nil?
|
|
297
|
+
|
|
298
|
+
cache_key = "#{@config.sdk_key}:#{experience_id}"
|
|
299
|
+
cached = self.class.cached_config_by_experience(cache_key)
|
|
300
|
+
return cached unless cached.nil?
|
|
301
|
+
|
|
302
|
+
fetch_config_by_experience(cache_key, experience_id)
|
|
303
|
+
end
|
|
304
|
+
|
|
170
305
|
private
|
|
171
306
|
|
|
172
307
|
# POST the drained per-visitor entries and branch on the result. On SUCCESS:
|
|
@@ -284,5 +419,37 @@ module ConvertSdk
|
|
|
284
419
|
|
|
285
420
|
{ "Authorization" => "Bearer #{secret}" }
|
|
286
421
|
end
|
|
422
|
+
|
|
423
|
+
# GET {config_by_experience_url(experience_id)} through the HTTP port; on
|
|
424
|
+
# success with a Hash body, memoize and return it, else return nil WITHOUT
|
|
425
|
+
# memoizing (qs-03 AC4/AC8). +@config.sdk_key+ is used directly (no
|
|
426
|
+
# account/project fallback — the config-fetch convention this endpoint
|
|
427
|
+
# mirrors, {Client#config_url}, uses +@config.sdk_key+ unconditionally; the
|
|
428
|
+
# fallback in {#sdk_key} is a track-URL-only concern).
|
|
429
|
+
def fetch_config_by_experience(cache_key, experience_id)
|
|
430
|
+
response = @http_client.request(method: :get, url: config_by_experience_url(experience_id), headers: auth_headers)
|
|
431
|
+
return nil unless response.success? && response.body.is_a?(Hash)
|
|
432
|
+
|
|
433
|
+
self.class.memoize_config_by_experience(cache_key, response.body)
|
|
434
|
+
response.body
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Build +{config_endpoint}/config/{sdk_key}+ with query params in the
|
|
438
|
+
# FIXED order the qs-03 contract mandates: +environment+ (when set), then
|
|
439
|
+
# +exp=<experience_id>+ (always), then +_conv_low_cache=1+ (always), then
|
|
440
|
+
# +debug_token+ (when set). Values are URI-encoded (mirrors
|
|
441
|
+
# {Client#config_url_params}'s encoding convention).
|
|
442
|
+
def config_by_experience_url(experience_id)
|
|
443
|
+
env = @config.environment
|
|
444
|
+
debug_token = @config.debug_token
|
|
445
|
+
|
|
446
|
+
params = [] #: Array[String]
|
|
447
|
+
params << "environment=#{URI.encode_www_form_component(env)}" unless env.nil?
|
|
448
|
+
params << "exp=#{URI.encode_www_form_component(experience_id)}"
|
|
449
|
+
params << "_conv_low_cache=1"
|
|
450
|
+
params << "debug_token=#{URI.encode_www_form_component(debug_token)}" unless debug_token.nil?
|
|
451
|
+
|
|
452
|
+
"#{@config.config_endpoint}/config/#{@config.sdk_key}?#{params.join("&")}"
|
|
453
|
+
end
|
|
287
454
|
end
|
|
288
455
|
end
|
|
@@ -39,6 +39,16 @@ module ConvertSdk
|
|
|
39
39
|
#
|
|
40
40
|
# @api private
|
|
41
41
|
class BucketingManager
|
|
42
|
+
# Fixed anchor-scale constant for the ANCHORED bucketing layout (contract
|
|
43
|
+
# v12, qs-01). Unlike +max_traffic+ (config-supplied, used only to scale the
|
|
44
|
+
# visitor's HASH value in {#value_visitor_based}), the anchor/width scale for
|
|
45
|
+
# the anchored range walk is a FIXED module constant in the JS oracle
|
|
46
|
+
# (+bucketing-manager.ts:25+ +DEFAULT_MAX_TRAFFIC = 10000+) — never read from
|
|
47
|
+
# config. Named here (rather than an inline literal in {#select_bucket_anchored})
|
|
48
|
+
# so the constant stays traceable to its JS origin without conflating it with
|
|
49
|
+
# the per-config +@max_traffic+ used elsewhere in this class.
|
|
50
|
+
ANCHORED_MAX_TRAFFIC = 10_000
|
|
51
|
+
|
|
42
52
|
# Build a bucketing engine bound to a {Config}'s frozen bucketing constants.
|
|
43
53
|
#
|
|
44
54
|
# @param config [Config] supplies +max_traffic+, +hash_seed+, +max_hash+.
|
|
@@ -130,5 +140,156 @@ module ConvertSdk
|
|
|
130
140
|
|
|
131
141
|
{ variation_id: selected, bucketing_allocation: value }
|
|
132
142
|
end
|
|
143
|
+
|
|
144
|
+
# Select the variation whose ANCHORED range contains +value+ (contract v12,
|
|
145
|
+
# qs-01/BUCK-2). Given the FULL ordered variation config list (active AND
|
|
146
|
+
# inactive/stopped arms — never pre-filtered), this folds the JS reference's
|
|
147
|
+
# +getBucketRanges+ + +selectBucketAnchored+ (bm.ts:145-190) into one pass so
|
|
148
|
+
# the method stays self-contained and independently unit-testable:
|
|
149
|
+
#
|
|
150
|
+
# allocation = anchored_allocation(ta) — Float(ta, exception: false) coercion; a
|
|
151
|
+
# nil coercion result (non-numeric/absent ta) defaults to 100.0, mirroring the
|
|
152
|
+
# JS oracle's `isNaN(ta) ? 100.0 : Number(ta)`
|
|
153
|
+
# active = anchored_active?(status, allocation) — ((status.nil? || status == "")
|
|
154
|
+
# ? true : status == "running") && allocation.positive?
|
|
155
|
+
# total_weight = sum(allocation) over ALL entries (active AND inactive)
|
|
156
|
+
# return nil if total_weight <= 0
|
|
157
|
+
# cum = 0.0
|
|
158
|
+
# each entry: anchor = (cum / total_weight) * ANCHORED_MAX_TRAFFIC
|
|
159
|
+
# width = active ? allocation * 100 : 0
|
|
160
|
+
# hit iff anchor <= value < anchor + width (first match wins)
|
|
161
|
+
# cum += allocation
|
|
162
|
+
#
|
|
163
|
+
# Inactive arms (stopped, or an explicit +traffic_allocation: 0+) keep their
|
|
164
|
+
# weight (anchor stability for the OTHER arms) but get zero width, so they
|
|
165
|
+
# can never themselves be selected. This is a DIFFERENT method from the
|
|
166
|
+
# packed {#select_bucket} — that packed walk is untouched for version<=11.
|
|
167
|
+
#
|
|
168
|
+
# @param variations [Array<Hash>] the FULL ordered variation config list
|
|
169
|
+
# (+"id"+, +"traffic_allocation"+, +"status"+) — inactive arms included.
|
|
170
|
+
# @param value [Integer] a bucket value in +[0, ANCHORED_MAX_TRAFFIC)+.
|
|
171
|
+
# @return [String, nil] the selected variation id, or +nil+ (including when
|
|
172
|
+
# +total_weight <= 0+ or the list is empty).
|
|
173
|
+
def select_bucket_anchored(variations, value)
|
|
174
|
+
entries = anchored_allocations(variations)
|
|
175
|
+
# NAIVE left-to-right fold — NOT +Array#sum+. Ruby's +Array#sum+ uses a
|
|
176
|
+
# Kahan-Babuska (Neumaier) compensated algorithm for Float arrays, which
|
|
177
|
+
# can differ from a plain fold at the float64 ULP level. The JS oracle
|
|
178
|
+
# computes +totalWeight+ via a naive +Array.prototype.reduce+
|
|
179
|
+
# (+bucketing-manager.ts:153-156+: +allocations.reduce((sum, {allocation})
|
|
180
|
+
# => sum + allocation, 0)+) — every other SDK (PHP/Python/Android/iOS)
|
|
181
|
+
# matches that same naive fold. A compensated sum here would diverge from
|
|
182
|
+
# every other SDK's +total_weight+ by 1+ ULP on some inputs, which can
|
|
183
|
+
# flip which arm a boundary +value+ resolves to (verified: a 3-way
|
|
184
|
+
# ~33.34/33.33/33.33-style split picks a different arm at its exact
|
|
185
|
+
# anchor boundary depending on the fold algorithm) — a cross-SDK parity
|
|
186
|
+
# break, not just a cosmetic rounding difference.
|
|
187
|
+
total_weight = entries.reduce(0.0) { |acc, entry| acc + entry[:allocation] }
|
|
188
|
+
variation = total_weight.positive? ? anchored_walk(entries, total_weight, value) : nil
|
|
189
|
+
|
|
190
|
+
@log_manager&.debug(
|
|
191
|
+
"BucketingManager#select_bucket_anchored: " \
|
|
192
|
+
"value=#{value} total_weight=#{total_weight} variation=#{variation.inspect}"
|
|
193
|
+
)
|
|
194
|
+
variation
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Resolve a visitor to a variation under the ANCHORED layout (contract v12),
|
|
198
|
+
# returning the SAME shape as {#bucket_for_visitor} (AC9 — no return-shape
|
|
199
|
+
# drift): +{variation_id:, bucketing_allocation:}+ or +nil+. Reuses the
|
|
200
|
+
# existing visitor-hash value unchanged (JS +getBucketForVisitorAnchored+,
|
|
201
|
+
# bm.ts:195-215) then resolves it through {#select_bucket_anchored}.
|
|
202
|
+
#
|
|
203
|
+
# @param variations [Array<Hash>] the FULL ordered variation config list
|
|
204
|
+
# (active AND inactive arms) — see {#select_bucket_anchored}.
|
|
205
|
+
# @param visitor_id [#to_s] the visitor identifier.
|
|
206
|
+
# @param experience_id [String] the experience identifier (default +""+).
|
|
207
|
+
# @param seed [Integer] MurmurHash3 seed (default Config hash seed).
|
|
208
|
+
# @return [Hash{Symbol=>Object}, nil] +{variation_id:, bucketing_allocation:}+
|
|
209
|
+
# or +nil+ (caller treats +nil+ as VARIATION_NOT_DECIDED).
|
|
210
|
+
def bucket_for_visitor_anchored(variations, visitor_id, experience_id: "", seed: @hash_seed)
|
|
211
|
+
value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed)
|
|
212
|
+
selected = select_bucket_anchored(variations, value)
|
|
213
|
+
|
|
214
|
+
@log_manager&.debug(
|
|
215
|
+
"BucketingManager#bucket_for_visitor_anchored: " \
|
|
216
|
+
"experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
|
|
217
|
+
"bucket_value=#{value} selected_variation_id=#{selected.inspect}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return nil if selected.nil?
|
|
221
|
+
|
|
222
|
+
{ variation_id: selected, bucketing_allocation: value }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Build +{id:, allocation:, active:}+ entries from the raw ordered variation
|
|
228
|
+
# config list, mirroring the JS +_buildVariationAllocations+ mapping
|
|
229
|
+
# (data-manager.ts:591-610). Entries without an +"id"+ are skipped entirely
|
|
230
|
+
# (never counted in +total_weight+, never selectable) — defensive against a
|
|
231
|
+
# sparse/malformed config row, same tolerance the rest of the SDK applies.
|
|
232
|
+
def anchored_allocations(variations)
|
|
233
|
+
variations.each_with_object([]) do |variation, entries|
|
|
234
|
+
next unless variation.is_a?(Hash) && variation["id"]
|
|
235
|
+
|
|
236
|
+
allocation = anchored_allocation(variation["traffic_allocation"])
|
|
237
|
+
active = anchored_active?(variation["status"], allocation)
|
|
238
|
+
entries << { id: variation["id"], allocation: allocation, active: active }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Mirrors the JS +isNaN(ta) ? 100.0 : Number(ta)+ mapping
|
|
243
|
+
# (data-manager.ts:594-601) via +Float(x, exception: false)+ coercion: a
|
|
244
|
+
# numeric-looking String (e.g. +"50"+) coerces to its numeric weight
|
|
245
|
+
# exactly like the JS oracle's +Number()+, not just a genuine
|
|
246
|
+
# Integer/Float. A genuinely non-numeric value, +nil+, or an absent field
|
|
247
|
+
# coerces to +nil+ and defaults to +100.0+ (full-allocation weight, AC5) —
|
|
248
|
+
# RBS core types +Float(untyped, exception: false)+ as +Float?+, so the
|
|
249
|
+
# nil-check both implements the JS default and narrows the return to the
|
|
250
|
+
# declared +-> Float+.
|
|
251
|
+
#
|
|
252
|
+
# Hard boundary (documented, not fixable in Ruby): +JSON.parse+ collapses
|
|
253
|
+
# an explicit JSON +null+ +traffic_allocation+ and an ABSENT field to the
|
|
254
|
+
# same Ruby +nil+, so this SDK cannot reproduce JS's split (JS: absent ->
|
|
255
|
+
# +undefined+ -> +isNaN+ -> 100.0/active; explicit +null+ ->
|
|
256
|
+
# +Number(null)+ is +0+ -> 0/inactive). We match JS for the served/tested
|
|
257
|
+
# case (absent -> 100.0/active); explicit-null +traffic_allocation+ is
|
|
258
|
+
# never served (0 occurrences in the 59-vector golden fixture). The same
|
|
259
|
+
# coercion-class divergence applies to an empty-string/whitespace-only
|
|
260
|
+
# +traffic_allocation+: +Float("", exception: false)+ (and whitespace-only
|
|
261
|
+
# strings) coerce to +nil+ -> defaults to 100.0/active here, whereas JS's
|
|
262
|
+
# +Number("")+ is +0+ -> 0/inactive; behaviorally neutral for the actual
|
|
263
|
+
# contract since +traffic_allocation+ is a backend-served numeric field
|
|
264
|
+
# and this value is never served (0 occurrences of empty-string/
|
|
265
|
+
# whitespace-only +ta+ in the 59-vector golden cross-SDK fixture).
|
|
266
|
+
def anchored_allocation(traffic_allocation)
|
|
267
|
+
coerced = Float(traffic_allocation, exception: false)
|
|
268
|
+
coerced.nil? ? 100.0 : coerced
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# +true+ when the variation is eligible for a non-zero anchored width: a
|
|
272
|
+
# +nil+/+""+ status defaults to running (JS +status ? status === RUNNING :
|
|
273
|
+
# true+, treating an empty string as falsy), AND the allocation is positive
|
|
274
|
+
# (an explicit +traffic_allocation: 0+ is inactive, never defaulted to 100).
|
|
275
|
+
def anchored_active?(status, allocation)
|
|
276
|
+
status_active = status.nil? || status == "" ? true : status == "running"
|
|
277
|
+
status_active && allocation.positive?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Walk the anchored +entries+ in config order, returning the first entry
|
|
281
|
+
# whose half-open +[anchor, anchor + width)+ band contains +value+, or +nil+
|
|
282
|
+
# when no band covers it. +total_weight+ is guaranteed positive by the caller.
|
|
283
|
+
def anchored_walk(entries, total_weight, value)
|
|
284
|
+
cum = 0.0
|
|
285
|
+
entries.each do |entry|
|
|
286
|
+
anchor = (cum / total_weight) * ANCHORED_MAX_TRAFFIC
|
|
287
|
+
width = entry[:active] ? entry[:allocation] * 100 : 0
|
|
288
|
+
return entry[:id] if value >= anchor && value < anchor + width
|
|
289
|
+
|
|
290
|
+
cum += entry[:allocation]
|
|
291
|
+
end
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
133
294
|
end
|
|
134
295
|
end
|
data/lib/convert_sdk/client.rb
CHANGED
|
@@ -347,12 +347,15 @@ module ConvertSdk
|
|
|
347
347
|
# Fetch config through the HTTP port and install it on success. A failed
|
|
348
348
|
# response degrades gracefully: it MAY fall back to a non-stale cached entry
|
|
349
349
|
# from the store (meaningful across processes with a shared store like
|
|
350
|
-
# Redis), otherwise a +warn+ line, no config, no raise.
|
|
350
|
+
# Redis), otherwise a +warn+ line, no config, no raise. The store fallback
|
|
351
|
+
# is skipped entirely when +debug_token+ is configured (qs-03 AC2 —
|
|
352
|
+
# always-live: a stale copy is misleading during QA, so a failed live
|
|
353
|
+
# fetch degrades straight to the no-config warn path).
|
|
351
354
|
def fetch_and_install_config
|
|
352
355
|
response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
|
|
353
356
|
if response.success? && response.body.is_a?(Hash)
|
|
354
357
|
install(response.body, "Client#initialize: installed fetched config")
|
|
355
|
-
elsif @data_manager.install_from_cache_if_fresh
|
|
358
|
+
elsif @config.debug_token.nil? && @data_manager.install_from_cache_if_fresh
|
|
356
359
|
@event_manager.fire(SystemEvents::READY, deferred: true)
|
|
357
360
|
else
|
|
358
361
|
@log_manager.warn(
|
|
@@ -378,14 +381,46 @@ module ConvertSdk
|
|
|
378
381
|
end
|
|
379
382
|
end
|
|
380
383
|
|
|
381
|
-
# Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with
|
|
382
|
-
#
|
|
383
|
-
|
|
384
|
+
# Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with
|
|
385
|
+
# query params appended in a fixed order — +environment+ (when configured),
|
|
386
|
+
# then +_conv_low_cache=1+ (when effective low-cache is active), then
|
|
387
|
+
# +debug_token=<value>+ (when configured) — joined with +&+ and prefixed
|
|
388
|
+
# with a single +?+ only when at least one param is present. +environment+
|
|
389
|
+
# stays first so the no-cache_level/no-override shape (the pre-RB-2 AC2
|
|
390
|
+
# regression lock) stays byte-identical: bare URL when no environment,
|
|
391
|
+
# +?environment=...+ with no trailing +&+ otherwise.
|
|
392
|
+
#
|
|
393
|
+
# +force_low_cache:+ is a private, internal-only seam (qs-03's per-fetch
|
|
394
|
+
# experiment-preview override) — not part of the public API. Effective
|
|
395
|
+
# low-cache is an OR of the override, the configured +cache_level+, and a
|
|
396
|
+
# configured +debug_token+ (qs-03 AC1 — a debug token always forces
|
|
397
|
+
# low-cache, regardless of +cache_level+):
|
|
398
|
+
# +force_low_cache || @config.cache_level == "low" || !@config.debug_token.nil?+.
|
|
399
|
+
# +debug_token+ requires no call-site argument — every #config_url call
|
|
400
|
+
# (construction fetch, refresh tick, timer-off refetch) picks it up
|
|
401
|
+
# directly from +@config.debug_token+.
|
|
402
|
+
def config_url(force_low_cache: false)
|
|
384
403
|
url = "#{@config.config_endpoint}/config/#{@config.sdk_key}"
|
|
404
|
+
params = config_url_params(force_low_cache)
|
|
405
|
+
|
|
406
|
+
return url if params.empty?
|
|
407
|
+
|
|
408
|
+
"#{url}?#{params.join("&")}"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# The ordered query-param list for {#config_url} — extracted so the URL
|
|
412
|
+
# builder itself stays small: +environment+, then +_conv_low_cache=1+
|
|
413
|
+
# (effective low-cache), then +debug_token+, each only when applicable.
|
|
414
|
+
def config_url_params(force_low_cache)
|
|
385
415
|
env = @config.environment
|
|
386
|
-
|
|
416
|
+
debug_token = @config.debug_token
|
|
417
|
+
low_cache = force_low_cache || @config.cache_level == "low" || !debug_token.nil?
|
|
387
418
|
|
|
388
|
-
|
|
419
|
+
params = [] #: Array[String]
|
|
420
|
+
params << "environment=#{URI.encode_www_form_component(env)}" unless env.nil?
|
|
421
|
+
params << "_conv_low_cache=1" if low_cache
|
|
422
|
+
params << "debug_token=#{URI.encode_www_form_component(debug_token)}" unless debug_token.nil?
|
|
423
|
+
params
|
|
389
424
|
end
|
|
390
425
|
|
|
391
426
|
# The fetch headers: an +Authorization: Bearer {secret}+ value when a secret
|
data/lib/convert_sdk/config.rb
CHANGED
|
@@ -82,7 +82,16 @@ module ConvertSdk
|
|
|
82
82
|
tracking: true,
|
|
83
83
|
# HTTP client timeouts in seconds (consumed by HttpClient, Story 1.5).
|
|
84
84
|
open_timeout: 5,
|
|
85
|
-
read_timeout: 10
|
|
85
|
+
read_timeout: 10,
|
|
86
|
+
# Network cache-level signal (qs-02); nil is a no-op, "low" appends the
|
|
87
|
+
# platform's low-cache query param to config-fetch URLs.
|
|
88
|
+
cache_level: nil,
|
|
89
|
+
# QA config-transport token (qs-03); nil is a no-op. When set, every
|
|
90
|
+
# config-fetch URL carries debug_token=<value> AND is forced to
|
|
91
|
+
# low-cache, config caching (read fallback + write-through) is disabled,
|
|
92
|
+
# and the value is redacted like sdk_key_secret (never logged in clear,
|
|
93
|
+
# never included in the wire payload built by #to_internal).
|
|
94
|
+
debug_token: nil
|
|
86
95
|
}.freeze
|
|
87
96
|
|
|
88
97
|
# @!attribute [r] sdk_key
|
|
@@ -121,11 +130,19 @@ module ConvertSdk
|
|
|
121
130
|
# @return [Numeric] HTTP connect timeout seconds (HttpClient, NFR3).
|
|
122
131
|
# @!attribute [r] read_timeout
|
|
123
132
|
# @return [Numeric] HTTP read timeout seconds (HttpClient, NFR3).
|
|
133
|
+
# @!attribute [r] cache_level
|
|
134
|
+
# @return [String, nil] network cache-level signal (JS +network.cacheLevel+);
|
|
135
|
+
# nil is a no-op, "low" appends a low-cache signal to config fetches (qs-02).
|
|
136
|
+
# @!attribute [r] debug_token
|
|
137
|
+
# @return [String, nil] QA config-transport token (qs-03); nil is a no-op.
|
|
138
|
+
# When set, forces a live, low-cache config fetch carrying
|
|
139
|
+
# +debug_token=<value>+ and disables config caching; redacted like
|
|
140
|
+
# +sdk_key_secret+ and never present in {#to_internal}.
|
|
124
141
|
attr_reader :sdk_key, :sdk_key_secret, :data, :environment,
|
|
125
142
|
:config_endpoint, :track_endpoint, :max_traffic, :hash_seed,
|
|
126
143
|
:data_refresh_interval, :event_batch_size, :flush_interval,
|
|
127
144
|
:keys_case_sensitive, :negation, :log_level, :tracking,
|
|
128
|
-
:open_timeout, :read_timeout
|
|
145
|
+
:open_timeout, :read_timeout, :cache_level, :debug_token
|
|
129
146
|
|
|
130
147
|
# Build a validated configuration from snake_case keyword options merged over
|
|
131
148
|
# {DEFAULTS}. Raises +ArgumentError+ (the SDK's only raising surface) on any
|
|
@@ -209,6 +226,7 @@ module ConvertSdk
|
|
|
209
226
|
|
|
210
227
|
log_manager.register_secret(@sdk_key) unless @sdk_key.nil?
|
|
211
228
|
log_manager.register_secret(@sdk_key_secret) unless @sdk_key_secret.nil?
|
|
229
|
+
log_manager.register_secret(@debug_token) unless @debug_token.nil?
|
|
212
230
|
end
|
|
213
231
|
end
|
|
214
232
|
end
|
|
@@ -20,7 +20,9 @@ module ConvertSdk
|
|
|
20
20
|
# * intervals — +data_refresh_interval+ / +flush_interval+ are Numeric or nil
|
|
21
21
|
# (nil = timer-off); +open_timeout+ / +read_timeout+ are Numeric;
|
|
22
22
|
# * booleans — +keys_case_sensitive+ / +tracking+ (strict true/false);
|
|
23
|
-
# * log level — must be one of the {LogLevel} values
|
|
23
|
+
# * log level — must be one of the {LogLevel} values;
|
|
24
|
+
# * cache level (qs-02) — must be +nil+ or the String +"low"+;
|
|
25
|
+
# * debug token (qs-03) — must be +nil+ or a String (no fixed allow-list).
|
|
24
26
|
class ConfigValidator
|
|
25
27
|
# The accepted {LogLevel} integer values (TRACE..SILENT).
|
|
26
28
|
LOG_LEVEL_VALUES = [
|
|
@@ -28,6 +30,9 @@ module ConvertSdk
|
|
|
28
30
|
LogLevel::WARN, LogLevel::ERROR, LogLevel::SILENT
|
|
29
31
|
].freeze
|
|
30
32
|
|
|
33
|
+
# The accepted +cache_level+ values (qs-02): nil (no-op) or "low".
|
|
34
|
+
CACHE_LEVEL_VALUES = [nil, "low"].freeze
|
|
35
|
+
|
|
31
36
|
# @param values [Hash{Symbol=>Object}] the merged option values, keyed by the
|
|
32
37
|
# public snake_case option names (the {Config::DEFAULTS} keys).
|
|
33
38
|
def initialize(values)
|
|
@@ -46,6 +51,7 @@ module ConvertSdk
|
|
|
46
51
|
validate_intervals!
|
|
47
52
|
validate_booleans!
|
|
48
53
|
validate_log_level!
|
|
54
|
+
validate_cache_level!
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
private
|
|
@@ -59,7 +65,7 @@ module ConvertSdk
|
|
|
59
65
|
|
|
60
66
|
# String-or-nil options, plus the Hash-or-nil data option.
|
|
61
67
|
def validate_strings!
|
|
62
|
-
%i[sdk_key sdk_key_secret environment config_endpoint track_endpoint negation].each do |name|
|
|
68
|
+
%i[sdk_key sdk_key_secret environment config_endpoint track_endpoint negation debug_token].each do |name|
|
|
63
69
|
require_string(name, @values[name])
|
|
64
70
|
end
|
|
65
71
|
require_type(:data, @values[:data], Hash) unless @values[:data].nil?
|
|
@@ -96,6 +102,16 @@ module ConvertSdk
|
|
|
96
102
|
"log_level must be a LogLevel value (#{LOG_LEVEL_VALUES.join(", ")}), got #{level.inspect}"
|
|
97
103
|
end
|
|
98
104
|
|
|
105
|
+
# cache_level (qs-02) must be nil or "low" — the platform's low-cache signal.
|
|
106
|
+
def validate_cache_level!
|
|
107
|
+
value = @values[:cache_level]
|
|
108
|
+
return if CACHE_LEVEL_VALUES.include?(value)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"cache_level must be nil or \"low\" (#{CACHE_LEVEL_VALUES.map(&:inspect).join(", ")}), " \
|
|
112
|
+
"got #{value.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
99
115
|
# Require String-or-nil (nil acceptable for optional strings).
|
|
100
116
|
def require_string(name, value)
|
|
101
117
|
return if value.nil? || value.is_a?(String)
|