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,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConvertSdk
6
+ # The outbound delivery manager — it owns the {VisitorsQueue}, the tracking
7
+ # endpoint, queue release, and THE wire-payload builder.
8
+ #
9
+ # == Wire-translation boundary #2 (the only outbound converter)
10
+ #
11
+ # {Config#to_internal} is the single INBOUND snake_case=>camelCase site; this
12
+ # class's payload builder is the single OUTBOUND one. Everything in between —
13
+ # +StoreData+, the queued events — is ALREADY wire-shaped, string-keyed data.
14
+ # The payload is therefore built EXCLUSIVELY here as string-keyed camelCase
15
+ # hashes and serialized with +JSON.generate+ — never string-concatenated JSON,
16
+ # never symbol keys anywhere in the wire hashes. The result is byte-identical to
17
+ # the JS wire contract (+api-manager.ts:197-234+).
18
+ #
19
+ # == The payload shape
20
+ #
21
+ # {
22
+ # "accountId" => …, "projectId" => …,
23
+ # "enrichData" => false, "source" => "ruby-sdk",
24
+ # "visitors" => [
25
+ # { "visitorId" => …, "segments" => {…}?, "events" => [ {…}, … ] }
26
+ # ]
27
+ # }
28
+ #
29
+ # POSTed to +{track_endpoint with [project_id] replaced}/track/{sdkKey}+ via the
30
+ # single {HttpClient} port (the ConvertAgent User-Agent invariant rides
31
+ # automatically; an +Authorization: Bearer {secret}+ header is passed through the
32
+ # port's +headers+ param when a secret is configured — the port enforces the
33
+ # HTTPS-only guard). An empty queue is a no-op.
34
+ #
35
+ # == enrichData / source (verified against JS source)
36
+ #
37
+ # +enrichData+ is +false+: the JS formula is +!objectDeepValue(config,'dataStore')+
38
+ # (+api-manager.ts:94+), which is +false+ whenever a dataStore is configured; the
39
+ # Ruby SDK always provides at least a MemoryStore, and the research register is
40
+ # silent on treating a MemoryStore-only config as "no store", so JS parity holds.
41
+ # +source+ is +"ruby-sdk"+ — the Ruby analogue of JS +config?.network?.source ||
42
+ # 'js-sdk'+ (+api-manager.ts:115+).
43
+ #
44
+ # == Lock discipline (NFR2/NFR13)
45
+ #
46
+ # {#release_queue} drains the queue with an atomic drain-and-swap INSIDE the
47
+ # queue lock, then builds the payload and performs the HTTP POST OUTSIDE the
48
+ # lock. The enqueue path never blocks the caller on network I/O. A failed POST
49
+ # does NOT raise (the full queue-retention behaviour lands in Story 4.2); it is
50
+ # logged and swallowed so the Client boundary never crashes the host.
51
+ #
52
+ # @api private
53
+ class ApiManager
54
+ # The SDK identifier sent as the tracking payload +source+ (JS analogue of
55
+ # +config?.network?.source || 'js-sdk'+ — api-manager.ts:115).
56
+ SOURCE = "ruby-sdk"
57
+
58
+ # JS parity: +!objectDeepValue(config,'dataStore')+ is false whenever a
59
+ # dataStore is configured, and Ruby always provides one (api-manager.ts:94).
60
+ ENRICH_DATA = false
61
+
62
+ # @param config [Config] the validated configuration (track endpoint, sdk_key,
63
+ # sdk_key_secret).
64
+ # @param data_manager [DataManager] supplies +account_id+ / +project_id+ for
65
+ # the payload and the +[project_id]+ URL substitution.
66
+ # @param http_client [HttpClient] the single hardened HTTP port.
67
+ # @param event_manager [EventManager] fires {SystemEvents::API_QUEUE_RELEASED}
68
+ # after a release (JS parity).
69
+ # @param log_manager [LogManager] the redacting logging surface.
70
+ def initialize(config:, data_manager:, http_client:, event_manager:, log_manager:)
71
+ @config = config
72
+ @data_manager = data_manager
73
+ @http_client = http_client
74
+ @event_manager = event_manager
75
+ @log_manager = log_manager
76
+ @queue = VisitorsQueue.new(log_manager: log_manager)
77
+ # The SECOND and FINAL BackgroundTimer instance (architecture Decision 6 —
78
+ # one class, two instances: the refresh timer is 2.7's, this is the flush
79
+ # timer, owned here). It is built and registered with ForkGuard NOW but
80
+ # NEVER started in the factory (NFR4 — no threads until first use); it is
81
+ # lazily started on the first enqueue. A +nil+ flush_interval is the
82
+ # timer-off mode (BackgroundTimer#start is then a guarded no-op — the
83
+ # Lambda recipe for 4.6: explicit flush + size trigger still deliver).
84
+ @flush_timer = BackgroundTimer.new(
85
+ interval: @config.flush_interval,
86
+ log_manager: log_manager,
87
+ name: "flush"
88
+ ) { flush_tick }
89
+ ForkGuard.register_timer(@flush_timer)
90
+ # Story 4.4 — child queue-ownership clear. ForkGuard fires this callback in
91
+ # a forked child (after marking timers dead). The child inherits a COPY of
92
+ # the parent's queued events; clearing it here is what makes the child
93
+ # start EMPTY so it never double-delivers the parent's events (the parent's
94
+ # timer still runs there and delivers them). ForkGuard stays generic — it
95
+ # knows nothing about the queue; ApiManager owns its own clear (architecture
96
+ # Decision 6 callback-registry design).
97
+ ForkGuard.register_child_callback(-> { clear_queue_ownership })
98
+ end
99
+
100
+ # @return [VisitorsQueue] the underlying per-visitor event queue.
101
+ attr_reader :queue
102
+
103
+ # Enqueue one wire-shaped event for a visitor (delegates to the queue's
104
+ # per-visitor merge), then drive the two automatic delivery triggers:
105
+ #
106
+ # 1. LAZY-START the flush timer (NFR4 — the first enqueue in each process is
107
+ # "first use"; idempotent + re-arms after a fork via 2.6's BackgroundTimer).
108
+ # 2. SIZE trigger — when the queue reaches +event_batch_size+, release with
109
+ # reason +"size"+ DIRECTLY on this thread (JS api-manager.ts:197-198). The
110
+ # enqueue itself is pure in-memory and the size-trigger release POSTs
111
+ # OUTSIDE the queue lock, so the caller is never blocked on the network
112
+ # (NFR2) — only the brief queue-lock acquisition.
113
+ #
114
+ # @param visitor_id [String] the visitor the event belongs to.
115
+ # @param event [Hash{String=>Object}] a wire-shaped event hash.
116
+ # @param segments [Hash{String=>Object}, nil] report-segments, attached only
117
+ # when this enqueue first creates the visitor's queue entry.
118
+ # @return [void]
119
+ def enqueue(visitor_id, event, segments: nil)
120
+ guard_fork_boundary
121
+ @queue.enqueue(visitor_id, event, segments: segments)
122
+ ensure_flush_timer!
123
+ release_queue("size") if @queue.size >= @config.event_batch_size
124
+ end
125
+
126
+ # Release the queue — the SINGLE delivery implementation all three triggers
127
+ # (explicit +flush+, size, interval) converge on. Drain-and-swap INSIDE the
128
+ # queue lock, then build the wire payload and POST it OUTSIDE the lock (the
129
+ # enqueue path is never blocked on network I/O — NFR2). An empty queue is a
130
+ # no-op.
131
+ #
132
+ # On SUCCESS: an +info+ line and the {SystemEvents::API_QUEUE_RELEASED}
133
+ # lifecycle event fire with a JS-parity payload (+reason+ + visitor count).
134
+ #
135
+ # On FAILURE (a failed {HttpClient::Response} — story 1.5 returns it WITHOUT
136
+ # raising): the drained visitors are RE-ENQUEUED via {VisitorsQueue#requeue}
137
+ # (preserving per-visitor merge), a +warn+ records the retention, and NO
138
+ # event fires. There is NO inline retry — a frozen divergence from PHP's
139
+ # 3-attempt backoff; the next attempt is the next timer tick or size trigger.
140
+ # The bounded queue (drop-oldest + warn at the 1000 cap) keeps a sustained
141
+ # outage from growing host memory without bound (NFR10).
142
+ #
143
+ # Never raises into the host (NFR9): a +rescue StandardError+ logs and
144
+ # swallows. Note the re-enqueue happens BEFORE the rescue so a transport-layer
145
+ # failed Response retains; a raise from the rescue path itself (after the
146
+ # drain) cannot retain, but the never-crash contract takes precedence there.
147
+ #
148
+ # @param reason [String, nil] a human-readable release reason (logged + fired).
149
+ # @return [void]
150
+ def release_queue(reason = nil)
151
+ # Story 4.4 — the SINGLE fork-safety PID boundary all three flush triggers
152
+ # (explicit flush, size, interval) inherit from one place. A cheap
153
+ # ForkGuard.forked? check (an integer PID comparison — Datadog idiom) covers
154
+ # the Process.daemon path that BYPASSES the _fork hook: a stale process
155
+ # re-arms (marks the inherited dead timers dead, clears the inherited queue,
156
+ # resets owner_pid) BEFORE proceeding. The check fires BEFORE the
157
+ # empty-queue early return so a freshly daemonised process re-arms its
158
+ # timers even when nothing is queued yet.
159
+ guard_fork_boundary
160
+
161
+ visitors = @queue.drain!
162
+ return if visitors.empty?
163
+
164
+ deliver(visitors, reason)
165
+ rescue StandardError => e
166
+ # Never-crash boundary: a delivery failure must not crash the host.
167
+ @log_manager.error("ApiManager#release_queue: #{e.class}: #{e.message}")
168
+ end
169
+
170
+ private
171
+
172
+ # POST the drained per-visitor entries and branch on the result. On SUCCESS:
173
+ # an +info+ line + the {SystemEvents::API_QUEUE_RELEASED} lifecycle event. On
174
+ # FAILURE: re-enqueue the drained visitors (preserving the per-visitor merge)
175
+ # and +warn+ — NO event fires (frozen Ruby divergence from JS, which DOES fire
176
+ # on failure — api-manager.ts:247). There is NO inline retry; the next timer
177
+ # tick / size trigger retries (the bounded queue keeps a sustained outage from
178
+ # growing host memory — NFR10). Caller wraps this in the never-crash rescue.
179
+ def deliver(visitors, reason)
180
+ response = post_payload(build_payload(visitors))
181
+ if response.success?
182
+ @log_manager.info(
183
+ "ApiManager#release_queue: queue released, reason=#{reason}, visitors=#{visitors.size}"
184
+ )
185
+ @event_manager.fire(
186
+ SystemEvents::API_QUEUE_RELEASED,
187
+ { "reason" => reason, "visitors" => visitors.size }
188
+ )
189
+ else
190
+ @queue.requeue(visitors)
191
+ @log_manager.warn(
192
+ "ApiManager#release_queue: delivery failed, retaining #{count_events(visitors)} events " \
193
+ "(status #{response.status}), reason=#{reason}"
194
+ )
195
+ end
196
+ end
197
+
198
+ # Story 4.4 — the PID-guarded fork boundary. When +ForkGuard.forked?+ is true
199
+ # (a +Process.daemon+ spawn bypassed the +_fork+ hook so owner_pid is stale),
200
+ # run the shared re-arm path before any delivery: it marks both registered
201
+ # timers dead, fires the child-callbacks (this manager's queue-ownership
202
+ # clear), and resets owner_pid — leaving the process behaving like a fresh
203
+ # child. A free no-op in the owning process and on JRuby (forked? is always
204
+ # false there).
205
+ def guard_fork_boundary
206
+ return unless ForkGuard.forked?
207
+
208
+ @log_manager.debug(
209
+ "ApiManager: stale process detected (fork/daemon bypass), re-arming"
210
+ )
211
+ ForkGuard.rearm!
212
+ end
213
+
214
+ # The registered ForkGuard child-callback (Story 4.4): clear this manager's
215
+ # inherited queue so a forked child starts EMPTY and never double-delivers
216
+ # the parent's events. ForkGuard fires this in the child after marking timers
217
+ # dead. Never-crash: a raising callback must not break the fork hook.
218
+ def clear_queue_ownership
219
+ @queue.clear
220
+ @log_manager.debug("ApiManager#clear_queue_ownership: cleared inherited queue ownership in child")
221
+ rescue StandardError => e
222
+ @log_manager.error("ApiManager#clear_queue_ownership: #{e.class}: #{e.message}")
223
+ end
224
+
225
+ # Lazily start the flush BackgroundTimer (NFR4 — never in the factory). Called
226
+ # on the first (and every) enqueue; idempotent and re-arms transparently after
227
+ # a fork (2.6 BackgroundTimer#start). A +nil+ flush_interval makes this a
228
+ # guarded no-op — no thread is ever created (timer-off mode).
229
+ def ensure_flush_timer!
230
+ @flush_timer.start
231
+ end
232
+
233
+ # The flush-timer tick body: release the queue with reason +"interval"+. Wrapped
234
+ # by BackgroundTimer's never-crash rescue (2.6); {#release_queue} additionally
235
+ # rescues internally so a tick never escapes.
236
+ def flush_tick
237
+ release_queue("interval")
238
+ end
239
+
240
+ # Total events across the given per-visitor entries (for the retention warn).
241
+ def count_events(visitors)
242
+ visitors.sum { |entry| entry["events"].size }
243
+ end
244
+
245
+ # Build the string-keyed camelCase wire payload (boundary #2). The drained
246
+ # visitor entries are already wire-shaped, so they ride verbatim.
247
+ def build_payload(visitors)
248
+ {
249
+ "accountId" => @data_manager.account_id,
250
+ "projectId" => @data_manager.project_id,
251
+ "enrichData" => ENRICH_DATA,
252
+ "source" => SOURCE,
253
+ "visitors" => visitors
254
+ }
255
+ end
256
+
257
+ # POST the payload to the project-scoped track URL through the HTTP port and
258
+ # return the frozen {HttpClient::Response}. The port serializes the body with
259
+ # +JSON.generate+, applies the ConvertAgent UA, and strips a Bearer header on a
260
+ # non-HTTPS endpoint. The port NEVER raises — a transport failure comes back as
261
+ # a failed Response (+success? == false+), so the caller branches on the result.
262
+ def post_payload(payload)
263
+ @http_client.request(method: :post, url: track_url, headers: auth_headers, body: payload)
264
+ end
265
+
266
+ # +{track_endpoint with [project_id] replaced}/track/{sdkKey}+ — JS
267
+ # api-manager.ts:221-229. The +sdk_key+ falls back to +"{accountId}/{projectId}"+
268
+ # when none is configured (JS +config?.sdkKey || `${accountId}/${projectId}`+).
269
+ def track_url
270
+ base = @config.track_endpoint.to_s.gsub("[project_id]", @data_manager.project_id.to_s)
271
+ "#{base}/track/#{sdk_key}"
272
+ end
273
+
274
+ # The SDK key path segment, with the JS account/project fallback.
275
+ def sdk_key
276
+ @config.sdk_key || "#{@data_manager.account_id}/#{@data_manager.project_id}"
277
+ end
278
+
279
+ # An +Authorization: Bearer {secret}+ header VALUE when a secret is configured,
280
+ # else none. The port owns the UA / HTTPS / plaintext-stripping mechanics.
281
+ def auth_headers
282
+ secret = @config.sdk_key_secret
283
+ return {} if secret.nil?
284
+
285
+ { "Authorization" => "Bearer #{secret}" }
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The SDK's single background-thread primitive — the ONLY +Thread.new+ site in
5
+ # the gem (architecture Decision 6, thread/fork boundary). One class, two
6
+ # future instances: the config-refresh timer (Story 2.7) and the queue-flush
7
+ # timer (Story 4.2). It is never subclassed.
8
+ #
9
+ # A timer wraps an +interval+, a tick +block+, and a Mutex'd lifecycle state
10
+ # machine. {#start} lazily spawns a single loop thread (NFR4 — no threads
11
+ # until first use); {#stop} signals it to exit and joins it; {#mark_dead}
12
+ # clears the state WITHOUT joining (the fork re-arm hook — a thread reference
13
+ # copied into a forked child is dead and joining it can hang), so the next
14
+ # {#start} transparently re-arms a fresh thread.
15
+ #
16
+ # The loop sleeps for +interval+ on a {Thread::ConditionVariable} (interruptible
17
+ # — {#stop} is responsive instead of waiting out a bare +sleep+), then runs the
18
+ # block. Each tick is wrapped in +rescue StandardError+ and logged (never
19
+ # +rescue Exception+): a raising tick is logged and the loop continues — an
20
+ # exception must never silently kill a timer thread (never-crash contract).
21
+ #
22
+ # A +nil+ or zero +interval+ is the timer-off mode: {#start} is a guarded
23
+ # no-op and no thread is ever created.
24
+ #
25
+ # @api private — not part of the public SDK surface.
26
+ class BackgroundTimer
27
+ # @param interval [Numeric, nil] seconds between ticks; +nil+/zero disables
28
+ # the timer (no thread is ever started).
29
+ # @param log_manager [ConvertSdk::LogManager] sink for debug (thread
30
+ # creation) and error (raising tick) lines.
31
+ # @param name [String] identifies the timer in log lines (e.g. "refresh").
32
+ # @yield the tick block, invoked once per interval.
33
+ def initialize(interval:, log_manager:, name:, &block)
34
+ @interval = interval
35
+ @log_manager = log_manager
36
+ @name = name
37
+ @block = block
38
+ # Thread safety: @thread and @running are guarded by @state_mutex; the
39
+ # condition variable wakes the loop's interruptible sleep on #stop.
40
+ @state_mutex = Thread::Mutex.new
41
+ @sleep_cv = Thread::ConditionVariable.new
42
+ @thread = nil
43
+ @running = false
44
+ end
45
+
46
+ # Start the loop thread if not already running. Idempotent: concurrent calls
47
+ # and repeat calls produce exactly one thread. Re-arms transparently after
48
+ # {#mark_dead}. A +nil+/zero interval is a no-op (timer-off mode).
49
+ # @return [void]
50
+ def start
51
+ @state_mutex.synchronize do
52
+ # Timer-off guard: a nil/zero interval never starts (and narrows the
53
+ # interval to a concrete positive Numeric for the loop's sleep).
54
+ interval = @interval
55
+ return if interval.nil? || interval <= 0
56
+ return if @running
57
+
58
+ @running = true
59
+ @thread = Thread.new { run_loop(interval.to_f) }
60
+ @log_manager.debug("BackgroundTimer#start: started ##{@name} (interval=#{@interval}s)")
61
+ end
62
+ end
63
+
64
+ # Signal the loop to exit and join the thread. Idempotent: a no-op when not
65
+ # running (including after {#mark_dead}).
66
+ # @return [void]
67
+ def stop
68
+ thread = nil #: Thread?
69
+ @state_mutex.synchronize do
70
+ return unless @running
71
+
72
+ @running = false
73
+ @sleep_cv.broadcast
74
+ thread = @thread
75
+ @thread = nil
76
+ end
77
+ thread&.join
78
+ end
79
+
80
+ # Fork re-arm hook: clear the lifecycle state WITHOUT joining the thread.
81
+ # The thread reference is stale in a forked child (fork copies only the
82
+ # calling thread), so joining it can hang. The next {#start} creates a fresh
83
+ # thread.
84
+ # @return [void]
85
+ def mark_dead
86
+ @state_mutex.synchronize do
87
+ @running = false
88
+ @thread = nil
89
+ end
90
+ end
91
+
92
+ # @return [Boolean] whether the loop thread is currently running.
93
+ def alive?
94
+ @state_mutex.synchronize { @running && !@thread.nil? }
95
+ end
96
+
97
+ private
98
+
99
+ # The loop body. Interruptible-sleeps for +interval+ (a concrete Float), then
100
+ # runs the tick under the never-crash rescue. Exits when +#stop+ clears the
101
+ # +running+ flag.
102
+ # @param interval [Float] validated sleep duration.
103
+ def run_loop(interval)
104
+ loop do
105
+ @state_mutex.synchronize do
106
+ break unless @running
107
+
108
+ @sleep_cv.wait(@state_mutex, interval)
109
+ end
110
+ break unless running?
111
+
112
+ tick
113
+ end
114
+ end
115
+
116
+ # @return [Boolean] a locked read of the running flag.
117
+ def running?
118
+ @state_mutex.synchronize { @running }
119
+ end
120
+
121
+ # Run the tick block under the never-crash contract: rescue StandardError,
122
+ # log, and let the loop continue. Never rescue Exception.
123
+ def tick
124
+ @block&.call
125
+ rescue StandardError => e
126
+ @log_manager.error("BackgroundTimer##{@name}: tick raised #{e.class}: #{e.message}")
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # A successfully resolved feature — a frozen value object returned when a
5
+ # feature decision is made (the success counterpart to a {Sentinel} miss).
6
+ #
7
+ # Implemented as a frozen +Struct+ subclass (NOT +Data.define+, which requires
8
+ # Ruby 3.2; this gem's floor is 3.1). Members are snake_case, aligned to the JS
9
+ # +BucketedFeature+ shape, verified against
10
+ # javascript-sdk/packages/types/src/BucketedFeature.ts and the vendored
11
+ # spec/fixtures/test-config.json feature entity. +status+ holds a
12
+ # {FeatureStatus} wire value; +variables+ is the feature's variable map.
13
+ class BucketedFeature < Struct.new(
14
+ :experience_id,
15
+ :experience_key,
16
+ :experience_name,
17
+ :id,
18
+ :key,
19
+ :name,
20
+ :status,
21
+ :variables,
22
+ keyword_init: true
23
+ )
24
+ # @return [void] builds the struct then freezes it (immutable value object).
25
+ def initialize(**)
26
+ super
27
+ freeze
28
+ end
29
+
30
+ # @return [Boolean] always false — a real decision is never a business miss.
31
+ def error?
32
+ false
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # A successfully bucketed variation — a frozen value object returned when a
5
+ # visitor IS decided into a variation (the success counterpart to a {Sentinel}
6
+ # business miss).
7
+ #
8
+ # Implemented as a frozen +Struct+ subclass (NOT +Data.define+, which requires
9
+ # Ruby 3.2; this gem's floor is 3.1). Members are snake_case, aligned to the JS
10
+ # +BucketedVariation+ shape (+ExperienceVariationConfig+ plus the experience
11
+ # fields), verified against javascript-sdk/packages/types/src/BucketedVariation.ts
12
+ # and the vendored spec/fixtures/test-config.json variation entity.
13
+ #
14
+ # v = ConvertSdk::BucketedVariation.new(key: "variation-a", id: "200381")
15
+ # case v.key
16
+ # when nil then show_default # a sentinel miss
17
+ # else render(v.key) # a real decision — error? is false
18
+ # end
19
+ class BucketedVariation < Struct.new(
20
+ :experience_id,
21
+ :experience_key,
22
+ :experience_name,
23
+ :bucketing_allocation,
24
+ :id,
25
+ :key,
26
+ :name,
27
+ :status,
28
+ :traffic_allocation,
29
+ :changes,
30
+ keyword_init: true
31
+ )
32
+ # @return [void] builds the struct then freezes it (immutable value object).
33
+ def initialize(**)
34
+ super
35
+ freeze
36
+ end
37
+
38
+ # @return [Boolean] always false — a real decision is never a business miss.
39
+ def error?
40
+ false
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Deterministic visitor bucketing — the cross-SDK variation-assignment engine.
5
+ #
6
+ # Given an experience id, a visitor id, and a caller-built +buckets+ hash
7
+ # (variation id => traffic percentage), this resolves a variation
8
+ # BYTE-IDENTICALLY to the JS SDK +bucketing-manager.ts+ and the proven PHP
9
+ # port +BucketingManager.php+. A visitor MUST bucket into the same variation on
10
+ # web (JS), PHP, and Ruby — the cross-SDK distribution spec is the CI proof.
11
+ #
12
+ # The pipeline mirrors JS exactly, link for link:
13
+ # 1. hash input = +experience_id + String(visitor_id)+ (experience FIRST, no
14
+ # delimiter) — JS +bucketing-manager.ts:97+, PHP +BucketingManager.php:89+.
15
+ # 2. +hash = MurmurHash3.hash(input, seed)+ — the proven Story 1.2 module;
16
+ # never reimplemented here.
17
+ # 3. +value = ((hash / 4_294_967_296.0) * max_traffic).to_i+ — float division
18
+ # then multiply then truncate, operation ORDER preserved. Ruby Float is
19
+ # IEEE-754 double like JS Number, and +Integer()+-via-+to_i+ truncates
20
+ # toward zero, matching JS +parseInt(String(val), 10)+ at +bm.ts:99+
21
+ # (behaviourally floor for all non-negative hash values).
22
+ # 4. +select_bucket+ walks variation cumulative ranges in insertion order:
23
+ # +prev += pct * 100 + redistribute+; the first variation satisfying the
24
+ # STRICT upper-bound +value < prev+ wins — JS +bm.ts:60-85+, PHP
25
+ # +BucketingManager.php:50-72+. No covering range => +nil+ (the caller
26
+ # treats +nil+ as VARIATION_NOT_DECIDED).
27
+ #
28
+ # Traffic allocation is NOT this class's concern: the caller
29
+ # (ExperienceManager/DataManager) constructs +buckets+ with only the
30
+ # traffic-allocated variations before invoking. BucketingManager is
31
+ # allocation-agnostic and answers one question deterministically: "given this
32
+ # experience config and this visitor id, which variation?"
33
+ #
34
+ # Pure in-memory computation (NFR1) — no I/O, no store access. Bucketing
35
+ # constants (+max_traffic+, +hash_seed+, +max_hash+) come from the injected
36
+ # {Config}, never inline literals. Logging stays at debug for the decisioning
37
+ # internals (FR56); never-crash is the caller's contract, but the class rescues
38
+ # nothing here because its inputs are caller-validated.
39
+ #
40
+ # @api private
41
+ class BucketingManager
42
+ # Build a bucketing engine bound to a {Config}'s frozen bucketing constants.
43
+ #
44
+ # @param config [Config] supplies +max_traffic+, +hash_seed+, +max_hash+.
45
+ # @param log_manager [LogManager, nil] optional debug logger for decisioning
46
+ # internals; absent in lean unit contexts.
47
+ def initialize(config:, log_manager: nil)
48
+ @max_traffic = config.max_traffic
49
+ @hash_seed = config.hash_seed
50
+ @max_hash = config.max_hash.to_f
51
+ @log_manager = log_manager
52
+ end
53
+
54
+ # Compute the deterministic bucket value for a visitor.
55
+ #
56
+ # @param visitor_id [#to_s] the visitor identifier (coerced via +String()+
57
+ # before hashing, matching JS +String(visitorId)+).
58
+ # @param experience_id [String] the experience identifier; prefixed to the
59
+ # visitor id to form the hash input. Defaults to +""+.
60
+ # @param seed [Integer] MurmurHash3 seed; defaults to the Config hash seed.
61
+ # @return [Integer] the bucket value in +[0, max_traffic)+.
62
+ def value_visitor_based(visitor_id, experience_id: "", seed: @hash_seed)
63
+ input = "#{experience_id}#{visitor_id}"
64
+ hash = MurmurHash3.hash(input, seed)
65
+ scaled = (hash / @max_hash) * @max_traffic
66
+ result = scaled.to_i
67
+
68
+ @log_manager&.debug(
69
+ "BucketingManager#value_visitor_based: " \
70
+ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
71
+ "seed=#{seed} hash=#{hash} scaled=#{scaled} result=#{result}"
72
+ )
73
+ result
74
+ end
75
+
76
+ # Select the variation whose cumulative range contains +value+.
77
+ #
78
+ # Walks +buckets+ in insertion order accumulating +pct * 100 + redistribute+
79
+ # per entry, returning the first variation id satisfying the strict
80
+ # upper-bound +value < prev+. Returns +nil+ when no range covers +value+
81
+ # (including an empty +buckets+ hash).
82
+ #
83
+ # @param buckets [Hash{String=>Numeric}] variation id => traffic percentage.
84
+ # @param value [Integer] a bucket value in +[0, max_traffic)+.
85
+ # @param redistribute [Numeric] per-bucket widening offset (default +0+).
86
+ # @return [String, nil] the selected variation id, or +nil+.
87
+ def select_bucket(buckets, value, redistribute = 0)
88
+ variation = nil
89
+ # Float accumulator: JS does `prev += buckets[id]*100 + redistribute` in
90
+ # IEEE-754 double arithmetic (bm.ts:68). Ruby Float is the same double, so
91
+ # accumulating in Float mirrors JS exactly. value (Integer) < prev (Float)
92
+ # compares identically to the JS strict upper-bound check.
93
+ prev = 0.0
94
+ buckets.each do |variation_id, percentage|
95
+ prev += (percentage.to_f * 100) + redistribute
96
+ if value < prev
97
+ variation = variation_id
98
+ break
99
+ end
100
+ end
101
+
102
+ @log_manager&.debug(
103
+ "BucketingManager#select_bucket: " \
104
+ "value=#{value} redistribute=#{redistribute} variation=#{variation.inspect}"
105
+ )
106
+ variation
107
+ end
108
+
109
+ # Resolve a visitor to a variation, returning the assignment and its bucket
110
+ # value, or +nil+ when no variation range covers the visitor.
111
+ #
112
+ # @param buckets [Hash{String=>Numeric}] variation id => traffic percentage.
113
+ # @param visitor_id [#to_s] the visitor identifier.
114
+ # @param experience_id [String] the experience identifier (default +""+).
115
+ # @param seed [Integer] MurmurHash3 seed (default Config hash seed).
116
+ # @param redistribute [Numeric] per-bucket widening offset (default +0+).
117
+ # @return [Hash{Symbol=>Object}, nil] +{variation_id:, bucketing_allocation:}+
118
+ # or +nil+ (caller treats +nil+ as VARIATION_NOT_DECIDED).
119
+ def bucket_for_visitor(buckets, visitor_id, experience_id: "", seed: @hash_seed, redistribute: 0)
120
+ value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed)
121
+ selected = select_bucket(buckets, value, redistribute)
122
+
123
+ @log_manager&.debug(
124
+ "BucketingManager#bucket_for_visitor: " \
125
+ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
126
+ "bucket_value=#{value} selected_variation_id=#{selected.inspect}"
127
+ )
128
+
129
+ return nil if selected.nil?
130
+
131
+ { variation_id: selected, bucketing_allocation: value }
132
+ end
133
+ end
134
+ end