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,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
# The SDK runtime handle returned by {ConvertSdk.create}.
|
|
7
|
+
#
|
|
8
|
+
# +Client+ owns the wiring of the injected managers (config, logging, HTTP,
|
|
9
|
+
# store, events, data) and drives the config lifecycle at construction:
|
|
10
|
+
#
|
|
11
|
+
# * *Direct data mode* (+data:+ supplied) — the inline object is normalised to
|
|
12
|
+
# string keys and installed straight into {DataManager}; NO config fetch
|
|
13
|
+
# happens (a single network-free path for testing / advanced setups).
|
|
14
|
+
# * *Fetch mode* (+sdk_key:+ only) — config is fetched via
|
|
15
|
+
# +GET {config_endpoint}/config/{sdkKey}+ (+?environment=...+ when set)
|
|
16
|
+
# through {HttpClient} ONLY, with +Authorization: Bearer {sdk_key_secret}+
|
|
17
|
+
# attached when a secret is configured. A failed fetch is logged at +warn+
|
|
18
|
+
# and the client is constructed WITHOUT config (degrade-gracefully, NFR12) —
|
|
19
|
+
# it never raises.
|
|
20
|
+
#
|
|
21
|
+
# == ready exactly once (FR9)
|
|
22
|
+
#
|
|
23
|
+
# The first successful config install (fetched OR direct) fires
|
|
24
|
+
# {SystemEvents::READY} exactly once for the client's lifetime; the once-guard
|
|
25
|
+
# is the +:first+ marker {DataManager#install_config} computes atomically inside
|
|
26
|
+
# its config mutex. Subsequent installs (Story 2.7's refresh) fire
|
|
27
|
+
# {SystemEvents::CONFIG_UPDATED}, never +ready+ again.
|
|
28
|
+
#
|
|
29
|
+
# == Never-crash boundary
|
|
30
|
+
#
|
|
31
|
+
# Every public method rescues +StandardError+, logs it, and returns its
|
|
32
|
+
# per-contract value — only {ConvertSdk.create}'s +ArgumentError+ (raised by
|
|
33
|
+
# {Config} on misconfiguration) is allowed to escape. The endpoints are touched
|
|
34
|
+
# ONLY through {HttpClient} (the single hardened HTTP port); the Client never
|
|
35
|
+
# touches the network library directly or builds wire headers beyond passing
|
|
36
|
+
# the Bearer header VALUE through the port.
|
|
37
|
+
#
|
|
38
|
+
# == Decisioning surface (fully wired)
|
|
39
|
+
#
|
|
40
|
+
# {#create_context} injects the per-context decisioning managers
|
|
41
|
+
# ({ExperienceManager}, {FeatureManager}, {SegmentsManager}) and the outbound
|
|
42
|
+
# {ApiManager} into each {Context}, so a context returned by a factory-built
|
|
43
|
+
# client decides through the real Story 2.9–3.2 machinery and enqueues bucketing
|
|
44
|
+
# / conversion events for delivery. No background threads are started by the
|
|
45
|
+
# Client or the factory (NFR4 lazy start); the refresh timer (Story 2.7),
|
|
46
|
+
# flush/fork/at_exit (Epic 4) wiring is lazy. Constructor injection throughout —
|
|
47
|
+
# no globals.
|
|
48
|
+
class Client
|
|
49
|
+
# @param config [Config] the validated configuration surface.
|
|
50
|
+
# @param log_manager [LogManager] shared logging surface (secrets armed).
|
|
51
|
+
# @param http_client [HttpClient] the single HTTP port (config fetch only here).
|
|
52
|
+
# @param data_store_manager [DataStoreManager] persistence port (wired; config
|
|
53
|
+
# caching is Story 2.7 — in-memory install only here).
|
|
54
|
+
# @param event_manager [EventManager] lifecycle pub/sub (fires +ready+).
|
|
55
|
+
# @param data_manager [DataManager] holds the deep-frozen config snapshot.
|
|
56
|
+
# @param api_manager [ApiManager] the outbound event queue + delivery surface
|
|
57
|
+
# (Story 4.1) — drives {#flush} and the bucketing-event enqueue seam.
|
|
58
|
+
# @param experience_manager [ExperienceManager, nil] the variation-selection
|
|
59
|
+
# surface (Story 2.11) injected into every {Context}. nil leaves contexts
|
|
60
|
+
# decisioning-less (the never-crash unit harness builds clients without it).
|
|
61
|
+
# @param feature_manager [FeatureManager, nil] the feature-resolution surface
|
|
62
|
+
# (Story 3.1) injected into every {Context}. nil leaves features miss-only.
|
|
63
|
+
# @param segments_manager [SegmentsManager, nil] the visitor-segmentation
|
|
64
|
+
# surface (Story 3.2) injected into every {Context}. nil leaves segmentation
|
|
65
|
+
# inert.
|
|
66
|
+
def initialize(config:, log_manager:, http_client:, data_store_manager:,
|
|
67
|
+
event_manager:, data_manager:, api_manager:,
|
|
68
|
+
experience_manager: nil, feature_manager: nil, segments_manager: nil)
|
|
69
|
+
@config = config
|
|
70
|
+
@log_manager = log_manager
|
|
71
|
+
@http_client = http_client
|
|
72
|
+
@data_store_manager = data_store_manager
|
|
73
|
+
@event_manager = event_manager
|
|
74
|
+
@data_manager = data_manager
|
|
75
|
+
@api_manager = api_manager
|
|
76
|
+
@experience_manager = experience_manager
|
|
77
|
+
@feature_manager = feature_manager
|
|
78
|
+
@segments_manager = segments_manager
|
|
79
|
+
# The lazily-started config-refresh BackgroundTimer (Story 2.7). Created
|
|
80
|
+
# here (interval bound, registered with ForkGuard) but NEVER started in the
|
|
81
|
+
# factory — #ensure_refresh_timer! starts it on first decision-path use
|
|
82
|
+
# (NFR4). A nil data_refresh_interval makes #start a guarded no-op (2.6),
|
|
83
|
+
# so timer-off mode never spawns a thread.
|
|
84
|
+
@refresh_timer = build_refresh_timer
|
|
85
|
+
# Wire the synchronous timer-off refresh callable into the DataManager so
|
|
86
|
+
# its decision-time TTL check (#ensure_fresh_config!) runs one full refresh
|
|
87
|
+
# cycle through the single HTTP port (the I/O happens under DataManager's
|
|
88
|
+
# thundering-herd fetch mutex, never the config mutex).
|
|
89
|
+
@data_manager.refetch = -> { refresh_config }
|
|
90
|
+
bootstrap_config
|
|
91
|
+
# Story 4.4 AC#5 — register the PID-guarded at_exit flush ONCE per client
|
|
92
|
+
# (the gem's ONLY at_exit site; the third and final single-site after the
|
|
93
|
+
# BackgroundTimer thread-spawn site and the ForkGuard _fork site).
|
|
94
|
+
# Registering an at_exit handler creates NO thread (NFR4-safe). The test
|
|
95
|
+
# harness disables live registration (the handler body is tested directly).
|
|
96
|
+
register_at_exit_flush
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
# Construction must never crash the host: log and continue config-less.
|
|
99
|
+
@log_manager.error("Client#initialize: #{e.class}: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Config] the configuration this client was built with.
|
|
103
|
+
attr_reader :config
|
|
104
|
+
|
|
105
|
+
# @return [DataManager] the config snapshot reader surface.
|
|
106
|
+
attr_reader :data_manager
|
|
107
|
+
|
|
108
|
+
# @return [ApiManager] the outbound event queue + delivery surface (Story 4.1).
|
|
109
|
+
attr_reader :api_manager
|
|
110
|
+
|
|
111
|
+
# Subscribe to a lifecycle event. Public API; delegates to {EventManager#on}
|
|
112
|
+
# (which normalises {SystemEvents} constants and matching strings to one key
|
|
113
|
+
# and replays deferred one-shot events to late subscribers).
|
|
114
|
+
#
|
|
115
|
+
# @param event [String] a {SystemEvents} value or matching string.
|
|
116
|
+
# @yieldparam payload [Object, nil]
|
|
117
|
+
# @yieldparam err [Object, nil]
|
|
118
|
+
# @return [self]
|
|
119
|
+
def on(event, &)
|
|
120
|
+
@event_manager.on(event, &)
|
|
121
|
+
self
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
@log_manager.error("Client#on: #{e.class}: #{e.message}")
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Boolean] true once a config snapshot is installed (degrade probe).
|
|
128
|
+
def config_available?
|
|
129
|
+
@data_manager.config_available?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Create a per-visitor decisioning {Context} — the object an integrator holds
|
|
133
|
+
# for the lifetime of one request/job.
|
|
134
|
+
#
|
|
135
|
+
# The +visitor_id+ is validated for presence (blank/nil → an +error+ log line
|
|
136
|
+
# + +nil+ return, NOT an +ArgumentError+: validation here is request-time, and
|
|
137
|
+
# the never-crash contract forbids raising into the host on a per-request call;
|
|
138
|
+
# only {ConvertSdk.create}'s config-time misconfiguration raises). Creation is
|
|
139
|
+
# the SDK's "first use" trigger, so it fires the lazy-start refresh-timer hook
|
|
140
|
+
# ({#ensure_refresh_timer!}, NFR4) — no threads start before the first context.
|
|
141
|
+
#
|
|
142
|
+
# Each call returns a NEW, independent {Context} (no caching, no shared mutable
|
|
143
|
+
# in-memory visitor state across instances — FR12); contexts for the same
|
|
144
|
+
# visitor share only the store-backed {StoreData} (stickiness). Attributes are
|
|
145
|
+
# deep-stringified at the {Context} boundary.
|
|
146
|
+
#
|
|
147
|
+
# @param visitor_id [String] the visitor id (must be non-blank).
|
|
148
|
+
# @param attributes [Hash, nil] optional per-visitor attributes.
|
|
149
|
+
# @return [Context, nil] the new Context, or nil for a blank visitor id.
|
|
150
|
+
def create_context(visitor_id = nil, attributes = nil)
|
|
151
|
+
if visitor_id.nil? || (visitor_id.respond_to?(:strip) && visitor_id.strip.empty?)
|
|
152
|
+
@log_manager.error("Client#create_context: blank visitor_id; returning nil")
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Story 4.4 — re-arm when a Process.daemon bypass left owner_pid stale.
|
|
157
|
+
# Mirrors ApiManager#guard_fork_boundary and Client#postfork: after rearm!
|
|
158
|
+
# marks the inherited refresh timer dead, the ensure_refresh_timer! call
|
|
159
|
+
# below spawns a fresh thread (BackgroundTimer#start is a no-op only when
|
|
160
|
+
# @running is true; mark_dead resets it to false). The check is a free PID
|
|
161
|
+
# comparison; always false on JRuby.
|
|
162
|
+
ForkGuard.rearm! if ForkGuard.forked?
|
|
163
|
+
|
|
164
|
+
# Context creation is "first use" — lazily arm the background refresh timer.
|
|
165
|
+
ensure_refresh_timer!
|
|
166
|
+
build_context(visitor_id, attributes)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
@log_manager.error("Client#create_context: #{e.class}: #{e.message}")
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Lazily start the background config-refresh timer (NFR4 — never in the
|
|
173
|
+
# factory). Called at the first decision-path entry (consumed by 2.8/2.11);
|
|
174
|
+
# idempotent (2.6 {BackgroundTimer#start} is idempotent and re-arms after a
|
|
175
|
+
# fork). A nil +data_refresh_interval+ makes this a guarded no-op (no thread
|
|
176
|
+
# is ever created — timer-off mode). Never raises into the host.
|
|
177
|
+
# @return [self]
|
|
178
|
+
def ensure_refresh_timer!
|
|
179
|
+
@refresh_timer.start
|
|
180
|
+
self
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
@log_manager.error("Client#ensure_refresh_timer!: #{e.class}: #{e.message}")
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Decision-time freshness hook for timer-off mode (Lambda/CLI). Delegates to
|
|
187
|
+
# {DataManager#ensure_fresh_config!}, which performs an on-demand TTL check
|
|
188
|
+
# and a synchronous, thundering-herd-guarded refetch when the cached config
|
|
189
|
+
# is stale. A no-op when the refresh timer is enabled. Never raises.
|
|
190
|
+
# @return [self]
|
|
191
|
+
def ensure_fresh_config!
|
|
192
|
+
@data_manager.ensure_fresh_config!
|
|
193
|
+
self
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
@log_manager.error("Client#ensure_fresh_config!: #{e.class}: #{e.message}")
|
|
196
|
+
self
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Explicitly release the queued visitor events synchronously (FR40). THE
|
|
200
|
+
# single flush entry point — delegates to {ApiManager#release_queue}, which
|
|
201
|
+
# drains-and-swaps inside the queue lock and POSTs OUTSIDE it (the enqueue
|
|
202
|
+
# path is never blocked on network I/O). A failed POST is logged inside the
|
|
203
|
+
# ApiManager and never raised; the full failed-POST queue-retention behaviour
|
|
204
|
+
# lands in Story 4.2. An empty queue is a no-op.
|
|
205
|
+
#
|
|
206
|
+
# +release_queues+ is the frozen-name alias (FR40) and shares this exact path.
|
|
207
|
+
#
|
|
208
|
+
# NOTE (Story 4.4 seam): this is the single point where a ForkGuard PID check
|
|
209
|
+
# will gate the flush in a forked child — added here so all flush callers
|
|
210
|
+
# (explicit, at_exit, timer) inherit it from one place.
|
|
211
|
+
#
|
|
212
|
+
# Never raises into the host (NFR9 never-crash boundary).
|
|
213
|
+
#
|
|
214
|
+
# @param reason [String, nil] a human-readable release reason (logged).
|
|
215
|
+
# @return [self]
|
|
216
|
+
def flush(reason = nil)
|
|
217
|
+
@api_manager.release_queue(reason)
|
|
218
|
+
self
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
@log_manager.error("Client#flush: #{e.class}: #{e.message}")
|
|
221
|
+
self
|
|
222
|
+
end
|
|
223
|
+
alias release_queues flush
|
|
224
|
+
|
|
225
|
+
# Manually re-arm the SDK after a fork (Story 4.4 AC#4 — frozen API name
|
|
226
|
+
# +postfork+). The exotic-setup escape hatch: in the default Puma/Unicorn/
|
|
227
|
+
# Sidekiq deployments the +Process._fork+ hook (ForkGuard) and the PID checks
|
|
228
|
+
# at flush boundaries detect forks AUTOMATICALLY with zero configuration, so
|
|
229
|
+
# +postfork+ is rarely needed. It exists for setups that bypass +_fork+
|
|
230
|
+
# entirely and never reach a flush boundary in time (or integrators who prefer
|
|
231
|
+
# an explicit +on_worker_boot+/+after_fork+ call, LaunchDarkly-style).
|
|
232
|
+
#
|
|
233
|
+
# Delegates to the SAME {ForkGuard.rearm!} path as automatic detection: marks
|
|
234
|
+
# both registered timers dead (lazy re-arm on next use), clears queue
|
|
235
|
+
# ownership in this process, and resets the owning PID. Idempotent (calling it
|
|
236
|
+
# in the owning process simply resets owner_pid to the current PID and
|
|
237
|
+
# re-fires the harmless clears). Never raises into the host (NFR9).
|
|
238
|
+
#
|
|
239
|
+
# @return [self]
|
|
240
|
+
def postfork
|
|
241
|
+
ForkGuard.rearm!
|
|
242
|
+
self
|
|
243
|
+
rescue StandardError => e
|
|
244
|
+
@log_manager.error("Client#postfork: #{e.class}: #{e.message}")
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Build a fully-wired {Context} for +visitor_id+: the shared config/store/event/
|
|
251
|
+
# log surfaces plus the per-context decisioning managers (Story 2.11 / 3.1 / 3.2)
|
|
252
|
+
# and the outbound {ApiManager} (Story 4.1). A nil decisioning manager leaves
|
|
253
|
+
# the corresponding Context method inert (the never-crash unit harness path).
|
|
254
|
+
def build_context(visitor_id, attributes)
|
|
255
|
+
Context.new(
|
|
256
|
+
visitor_id: visitor_id,
|
|
257
|
+
attributes: attributes,
|
|
258
|
+
data_manager: @data_manager,
|
|
259
|
+
data_store_manager: @data_store_manager,
|
|
260
|
+
event_manager: @event_manager,
|
|
261
|
+
log_manager: @log_manager,
|
|
262
|
+
config: @config,
|
|
263
|
+
experience_manager: @experience_manager,
|
|
264
|
+
feature_manager: @feature_manager,
|
|
265
|
+
segments_manager: @segments_manager,
|
|
266
|
+
api_manager: @api_manager
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Story 4.4 AC#5 — capture the registering PID and register the gem's single
|
|
271
|
+
# at_exit handler (the Split-gem ROOT_PROCESS_ID pattern). Skipped under the
|
|
272
|
+
# test harness (+ConvertSdk.at_exit_registration_enabled?+ is false there) so
|
|
273
|
+
# specs never register a live handler that would fire flush at suite exit.
|
|
274
|
+
# Registering an at_exit handler creates no thread (NFR4-safe).
|
|
275
|
+
def register_at_exit_flush
|
|
276
|
+
@at_exit_pid = Process.pid
|
|
277
|
+
return unless ConvertSdk.at_exit_registration_enabled?
|
|
278
|
+
|
|
279
|
+
at_exit { run_at_exit_flush }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The at_exit handler body (Story 4.4 AC#5). PID-guarded: it flushes ONLY in
|
|
283
|
+
# the process that registered it — a forked child that inherited the handler
|
|
284
|
+
# is suppressed (its events belong to the child's own lifecycle, and the
|
|
285
|
+
# parent flushes the parent's). Best-effort: it does not run under SIGKILL
|
|
286
|
+
# (Lambda) — that path relies on the size/interval triggers. Never raises at
|
|
287
|
+
# exit (a raise during interpreter teardown is especially hostile).
|
|
288
|
+
def run_at_exit_flush
|
|
289
|
+
if Process.pid == @at_exit_pid
|
|
290
|
+
@log_manager.debug("Client#run_at_exit_flush: registering process exiting, flushing")
|
|
291
|
+
@api_manager.release_queue("exit")
|
|
292
|
+
else
|
|
293
|
+
@log_manager.debug("Client#run_at_exit_flush: suppressed in forked child (pid mismatch)")
|
|
294
|
+
end
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
@log_manager.error("Client#run_at_exit_flush: #{e.class}: #{e.message}")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Build the config-refresh BackgroundTimer bound to +data_refresh_interval+
|
|
300
|
+
# and register it with ForkGuard (so a forked child marks it dead and the
|
|
301
|
+
# next #ensure_refresh_timer! re-arms it). The tick body is {#refresh_config}.
|
|
302
|
+
def build_refresh_timer
|
|
303
|
+
timer = BackgroundTimer.new(
|
|
304
|
+
interval: @config.data_refresh_interval,
|
|
305
|
+
log_manager: @log_manager,
|
|
306
|
+
name: "refresh"
|
|
307
|
+
) { refresh_config }
|
|
308
|
+
ForkGuard.register_timer(timer)
|
|
309
|
+
timer
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# One refresh cycle (the timer tick AND the synchronous timer-off refetch
|
|
313
|
+
# share this path): refetch through the HTTP port and, on success, install
|
|
314
|
+
# (deep-freeze + atomic swap + cache write via DataManager) which fires
|
|
315
|
+
# +config.updated+ (never +ready+ again — the 2.5 ready-once guard). A failed
|
|
316
|
+
# refetch keeps the current snapshot, warns, and lets the timer retry on the
|
|
317
|
+
# next tick (no inline retry, no backoff — frozen decision).
|
|
318
|
+
def refresh_config
|
|
319
|
+
envelope = refetch_config
|
|
320
|
+
if envelope.is_a?(Hash)
|
|
321
|
+
install(envelope, "Client#refresh_config: refreshed config installed")
|
|
322
|
+
else
|
|
323
|
+
@log_manager.warn("Client#refresh_config: refresh failed, serving cached config")
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Refetch the config through the single HTTP port and return the parsed
|
|
328
|
+
# envelope on success, or nil on any failed response. The single network
|
|
329
|
+
# refetch primitive — used by the timer tick and the timer-off synchronous
|
|
330
|
+
# refresh; the I/O here never holds the config mutex.
|
|
331
|
+
def refetch_config
|
|
332
|
+
response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
|
|
333
|
+
response.success? && response.body.is_a?(Hash) ? response.body : nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Drive the config lifecycle at construction: direct-data install when a
|
|
337
|
+
# +data:+ object was supplied, otherwise a live fetch. Either path that
|
|
338
|
+
# yields a usable config installs it identically (and fires +ready+ once).
|
|
339
|
+
def bootstrap_config
|
|
340
|
+
if @config.data.nil?
|
|
341
|
+
fetch_and_install_config
|
|
342
|
+
else
|
|
343
|
+
install(@config.data, "Client#initialize: installed direct data config")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Fetch config through the HTTP port and install it on success. A failed
|
|
348
|
+
# response degrades gracefully: it MAY fall back to a non-stale cached entry
|
|
349
|
+
# from the store (meaningful across processes with a shared store like
|
|
350
|
+
# Redis), otherwise a +warn+ line, no config, no raise.
|
|
351
|
+
def fetch_and_install_config
|
|
352
|
+
response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
|
|
353
|
+
if response.success? && response.body.is_a?(Hash)
|
|
354
|
+
install(response.body, "Client#initialize: installed fetched config")
|
|
355
|
+
elsif @data_manager.install_from_cache_if_fresh
|
|
356
|
+
@event_manager.fire(SystemEvents::READY, deferred: true)
|
|
357
|
+
else
|
|
358
|
+
@log_manager.warn(
|
|
359
|
+
"Client#initialize: config fetch failed (status #{response.status}); " \
|
|
360
|
+
"continuing without config"
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Install a config envelope and fire the correct lifecycle event based on the
|
|
366
|
+
# atomic first/updated marker from {DataManager}. Symbol-keyed inputs (direct
|
|
367
|
+
# data mode) are normalised to string keys at this public boundary before
|
|
368
|
+
# install so readers see the same string-keyed wire shape as fetched config.
|
|
369
|
+
def install(source, log_message)
|
|
370
|
+
marker = @data_manager.install_config(stringify_keys(source))
|
|
371
|
+
return unless marker
|
|
372
|
+
|
|
373
|
+
@log_manager.info(log_message)
|
|
374
|
+
if marker == :first
|
|
375
|
+
@event_manager.fire(SystemEvents::READY, deferred: true)
|
|
376
|
+
else
|
|
377
|
+
@event_manager.fire(SystemEvents::CONFIG_UPDATED)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with an
|
|
382
|
+
# +environment+ query parameter appended only when one is configured.
|
|
383
|
+
def config_url
|
|
384
|
+
url = "#{@config.config_endpoint}/config/#{@config.sdk_key}"
|
|
385
|
+
env = @config.environment
|
|
386
|
+
return url if env.nil?
|
|
387
|
+
|
|
388
|
+
"#{url}?environment=#{URI.encode_www_form_component(env)}"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The fetch headers: an +Authorization: Bearer {secret}+ value when a secret
|
|
392
|
+
# is configured, else none. The port (HttpClient) owns UA / HTTPS / Bearer
|
|
393
|
+
# plaintext-stripping mechanics — the Client only supplies the header VALUE.
|
|
394
|
+
def fetch_headers
|
|
395
|
+
secret = @config.sdk_key_secret
|
|
396
|
+
return {} if secret.nil?
|
|
397
|
+
|
|
398
|
+
{ "Authorization" => "Bearer #{secret}" }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Recursively normalise a (possibly symbol-keyed) config object to string
|
|
402
|
+
# keys — the public-boundary normalisation for direct data mode, so the
|
|
403
|
+
# installed snapshot matches the string-keyed fetched wire shape exactly.
|
|
404
|
+
def stringify_keys(node)
|
|
405
|
+
case node
|
|
406
|
+
when Hash
|
|
407
|
+
result = {} #: Hash[String, untyped]
|
|
408
|
+
node.each { |k, v| result[k.to_s] = stringify_keys(v) }
|
|
409
|
+
result
|
|
410
|
+
when Array
|
|
411
|
+
node.map { |element| stringify_keys(element) }
|
|
412
|
+
else
|
|
413
|
+
node
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The 13 rule comparison operators — the cross-SDK audience-targeting predicate
|
|
5
|
+
# set, ported BYTE-FOR-BYTE from the JS SDK
|
|
6
|
+
# +packages/utils/src/comparisons.ts+.
|
|
7
|
+
#
|
|
8
|
+
# JS is the ONLY truth here. The PHP reference is QUARANTINED for this surface:
|
|
9
|
+
# it ships zero +exists+/+not_exists+ handling (a disk-verified gap) and folds
|
|
10
|
+
# case on the +isIn+ values side (a second divergence), so it must not influence
|
|
11
|
+
# the Ruby contract. Every operator below mirrors its JS body at the cited
|
|
12
|
+
# +comparisons.ts+ line; the goldens in the cross-SDK vector suite are the CI
|
|
13
|
+
# proof of parity.
|
|
14
|
+
#
|
|
15
|
+
# Two-worlds dispatch (operators): the platform sends operator names as
|
|
16
|
+
# camelCase WIRE strings inside the rule JSON (+equalsNumber+, +startsWith+, …).
|
|
17
|
+
# Those strings are config-world identifiers and stay byte-identical; the Ruby
|
|
18
|
+
# methods underneath are snake_case. {dispatch} is the map from the wire name to
|
|
19
|
+
# the Ruby method symbol, consumed by {RuleManager}.
|
|
20
|
+
#
|
|
21
|
+
# The undefined/nil distinction (the subtle one): JS distinguishes +undefined+
|
|
22
|
+
# (a data key is ABSENT) from +null+ (the key is present with a null value).
|
|
23
|
+
# Ruby hashes collapse both to +nil+, so absence is modeled EXPLICITLY with the
|
|
24
|
+
# frozen private {UNDEFINED} marker — {RuleManager} passes {UNDEFINED} for an
|
|
25
|
+
# absent key so the existence operators (and JS-parity need-more-data
|
|
26
|
+
# propagation) behave exactly as JS does with +undefined+. For the existence
|
|
27
|
+
# operators themselves +UNDEFINED+, +nil+, and +""+ are all "does not exist",
|
|
28
|
+
# matching +value !== undefined && value !== null && value !== ''+
|
|
29
|
+
# (+comparisons.ts:159+).
|
|
30
|
+
#
|
|
31
|
+
# Pure and stateless (NFR1): every method is a singleton (+self.+) method with
|
|
32
|
+
# no I/O and no instance state (the same module form as {MurmurHash3}).
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
module Comparisons
|
|
36
|
+
# Sentinel for an ABSENT data key, distinct from +nil+ (a present null value).
|
|
37
|
+
# Frozen so it is a stable, comparable singleton. Mirrors JS +undefined+ on
|
|
38
|
+
# the rule-evaluation path.
|
|
39
|
+
UNDEFINED = Object.new.freeze
|
|
40
|
+
|
|
41
|
+
# JS +isNumeric+ regex (+string-utils.ts:69+): optional leading minus, then
|
|
42
|
+
# either grouped thousands (+1,234+) or plain digits, with an optional
|
|
43
|
+
# fractional part, or a bare fraction (+.5+).
|
|
44
|
+
NUMERIC_REGEXP = /\A-?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)\z/
|
|
45
|
+
|
|
46
|
+
# Case-insensitive equality. Mirrors +comparisons.ts:15-40+:
|
|
47
|
+
# Array value -> membership of +test_against+; non-empty Hash value -> key
|
|
48
|
+
# membership; otherwise both sides are stringified, lowercased, and compared.
|
|
49
|
+
#
|
|
50
|
+
# @param value [Object] the data value (String, Numeric, Boolean, Array, Hash).
|
|
51
|
+
# @param test_against [Object] the rule's expected value.
|
|
52
|
+
# @param negation [Boolean] when true, the result is inverted.
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def self.equals(value, test_against, negation = false)
|
|
55
|
+
return negation_check(value.include?(test_against), negation) if value.is_a?(Array)
|
|
56
|
+
return negation_check(value.key?(test_against.to_s), negation) if value.is_a?(Hash) && !value.empty?
|
|
57
|
+
|
|
58
|
+
negation_check(value.to_s.downcase == test_against.to_s.downcase, negation)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Alias of {equals} (+comparisons.ts:42+ — +equalsNumber = this.equals+).
|
|
62
|
+
def self.equals_number(value, test_against, negation = false)
|
|
63
|
+
equals(value, test_against, negation)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Alias of {equals} (+comparisons.ts:43+ — +matches = this.equals+).
|
|
67
|
+
def self.matches(value, test_against, negation = false)
|
|
68
|
+
equals(value, test_against, negation)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Strict less-than over numerically-normalized inputs (+comparisons.ts:45-56+).
|
|
72
|
+
# Numeric-looking strings/numbers normalize to a number; anything else keeps
|
|
73
|
+
# its type. When the normalized types differ the result is +false+ (JS
|
|
74
|
+
# +typeof value !== typeof testAgainst+, ts:52-54).
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def self.less(value, test_against, negation = false)
|
|
78
|
+
compare_numeric(value, test_against, negation) { |a, b| a < b }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Less-than-or-equal counterpart of {less} (+comparisons.ts:58-69+).
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def self.less_equal(value, test_against, negation = false)
|
|
85
|
+
compare_numeric(value, test_against, negation) { |a, b| a <= b }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Case-insensitive substring test (+comparisons.ts:71-87+). PRESERVED JS
|
|
89
|
+
# quirk: an empty or whitespace-only +test_against+ returns +true+
|
|
90
|
+
# (ts:80-81) — do not "fix" it.
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def self.contains(value, test_against, negation = false)
|
|
94
|
+
value = value.to_s.downcase
|
|
95
|
+
test_against = test_against.to_s.downcase
|
|
96
|
+
return negation_check(true, negation) if test_against.gsub(/\A\s*|\s*\z/, "").empty?
|
|
97
|
+
|
|
98
|
+
negation_check(value.include?(test_against), negation)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Pipe-split membership (+comparisons.ts:89-115+). BOTH +values+ and a string
|
|
102
|
+
# +test_against+ are split on the +splitter+. Only the +test_against+ items
|
|
103
|
+
# are lowercased after splitting; the +values+ items are compared AS-IS
|
|
104
|
+
# against the lowercased list (so an uppercased value does not match a
|
|
105
|
+
# lowercased entry — exact JS semantics at ts:106-110).
|
|
106
|
+
#
|
|
107
|
+
# @param values [Object] the data value (pipe-joined string or scalar).
|
|
108
|
+
# @param test_against [Object] an Array, or a pipe-joined string to split.
|
|
109
|
+
# @param negation [Boolean]
|
|
110
|
+
# @param splitter [String] the delimiter (default +"|"+).
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def self.is_in(values, test_against, negation = false, splitter = "|")
|
|
113
|
+
matched_values = values.to_s.split(splitter, -1).map(&:to_s)
|
|
114
|
+
test_against = test_against.split(splitter, -1) if test_against.is_a?(String)
|
|
115
|
+
unless test_against.is_a?(Array)
|
|
116
|
+
test_against = [] #: Array[untyped]
|
|
117
|
+
end
|
|
118
|
+
test_against = test_against.map { |item| item.to_s.downcase }
|
|
119
|
+
negation_check(matched_values.any? { |item| test_against.include?(item) }, negation)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Case-insensitive prefix test (+comparisons.ts:117-128+ — +indexOf === 0+).
|
|
123
|
+
#
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def self.starts_with(value, test_against, negation = false)
|
|
126
|
+
value = value.to_s.downcase
|
|
127
|
+
test_against = test_against.to_s.downcase
|
|
128
|
+
negation_check(value.start_with?(test_against), negation)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Case-insensitive suffix test (+comparisons.ts:130-141+).
|
|
132
|
+
#
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def self.ends_with(value, test_against, negation = false)
|
|
135
|
+
value = value.to_s.downcase
|
|
136
|
+
test_against = test_against.to_s.downcase
|
|
137
|
+
negation_check(value.end_with?(test_against), negation)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Case-insensitive regex test (+comparisons.ts:143-152+ — +new RegExp(t, 'i')+).
|
|
141
|
+
# +value+ is lowercased; the pattern keeps its case but matches
|
|
142
|
+
# case-insensitively via the +i+ flag.
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean]
|
|
145
|
+
def self.regex_matches(value, test_against, negation = false)
|
|
146
|
+
value = value.to_s.downcase
|
|
147
|
+
pattern = Regexp.new(test_against.to_s, Regexp::IGNORECASE)
|
|
148
|
+
negation_check(!pattern.match(value).nil?, negation)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Presence test (+comparisons.ts:154-161+): true unless the value is
|
|
152
|
+
# +UNDEFINED+ (JS +undefined+), +nil+ (JS +null+), or the empty string.
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean]
|
|
155
|
+
def self.exists(value, _test_against = nil, negation = false)
|
|
156
|
+
value_exists = !value.equal?(UNDEFINED) && !value.nil? && value != ""
|
|
157
|
+
negation_check(value_exists, negation)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Absence test — the logical inverse of {exists} (+comparisons.ts:163-170+).
|
|
161
|
+
#
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def self.not_exists(value, _test_against = nil, negation = false)
|
|
164
|
+
value_not_exists = value.equal?(UNDEFINED) || value.nil? || value == ""
|
|
165
|
+
negation_check(value_not_exists, negation)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Alias of {not_exists} (+comparisons.ts:172+ — +doesNotExist = this.not_exists+).
|
|
169
|
+
def self.does_not_exist(value, test_against = nil, negation = false)
|
|
170
|
+
not_exists(value, test_against, negation)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Maps each wire comparison operator name to its implementing method symbol.
|
|
174
|
+
DISPATCH = {
|
|
175
|
+
"equals" => :equals,
|
|
176
|
+
"equalsNumber" => :equals_number,
|
|
177
|
+
"matches" => :matches,
|
|
178
|
+
"less" => :less,
|
|
179
|
+
"lessEqual" => :less_equal,
|
|
180
|
+
"contains" => :contains,
|
|
181
|
+
"isIn" => :is_in,
|
|
182
|
+
"startsWith" => :starts_with,
|
|
183
|
+
"endsWith" => :ends_with,
|
|
184
|
+
"regexMatches" => :regex_matches,
|
|
185
|
+
"exists" => :exists,
|
|
186
|
+
"not_exists" => :not_exists,
|
|
187
|
+
"doesNotExist" => :does_not_exist
|
|
188
|
+
}.freeze
|
|
189
|
+
|
|
190
|
+
# The wire-name -> Ruby-method dispatch map (the two-worlds rule for
|
|
191
|
+
# operators). Keys are byte-identical to the rule JSON +match_type+ strings;
|
|
192
|
+
# {RuleManager} looks an operator up here and invokes the mapped method.
|
|
193
|
+
#
|
|
194
|
+
# @return [Hash{String=>Symbol}] frozen 13-entry map.
|
|
195
|
+
def self.dispatch
|
|
196
|
+
DISPATCH
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# JS +isNumeric+ (+string-utils.ts:68-74+): numbers are numeric when finite;
|
|
200
|
+
# strings are numeric only when they match {NUMERIC_REGEXP} and parse finite.
|
|
201
|
+
# @api private
|
|
202
|
+
def self.numeric?(value)
|
|
203
|
+
return value.finite? if value.is_a?(Numeric)
|
|
204
|
+
return false unless value.is_a?(String) && NUMERIC_REGEXP.match?(value)
|
|
205
|
+
|
|
206
|
+
Float(value.delete(",")).finite?
|
|
207
|
+
rescue ArgumentError, TypeError
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# JS +toNumber+ (+string-utils.ts:81-91+): numbers pass through; strings with
|
|
212
|
+
# a leading +"0"+ thousands segment treat commas as decimal points (all commas
|
|
213
|
+
# replaced with dots via +tr+, matching JS's global +replace(/,/g, '.')+),
|
|
214
|
+
# otherwise commas are stripped. The result is parsed with +to_f+ (lenient,
|
|
215
|
+
# never raises) — matching JS +parseFloat()+: the leading numeric portion is
|
|
216
|
+
# extracted and a trailing-garbage input like +"0.123.456"+ returns +0.123+
|
|
217
|
+
# rather than raising. This is a verified parity fix: Ruby +Float()+ (strict)
|
|
218
|
+
# raises on that input while JS +parseFloat+ and Ruby +to_f+ do not.
|
|
219
|
+
# @api private
|
|
220
|
+
def self.to_number(value)
|
|
221
|
+
return value if value.is_a?(Numeric)
|
|
222
|
+
|
|
223
|
+
str = value.to_s
|
|
224
|
+
parts = str.split(",")
|
|
225
|
+
normalized = parts[0] == "0" ? str.tr(",", ".") : str.delete(",")
|
|
226
|
+
normalized.to_f
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Numeric comparison core shared by {less}/{lessEqual}. Both sides are
|
|
230
|
+
# numerically normalized when numeric-looking; if the resulting types differ
|
|
231
|
+
# (one number, one non-number) the comparison is +false+ — JS ts:52-54/65-67.
|
|
232
|
+
# @api private
|
|
233
|
+
def self.compare_numeric(value, test_against, negation)
|
|
234
|
+
value = to_number(value) if numeric?(value)
|
|
235
|
+
test_against = to_number(test_against) if numeric?(test_against)
|
|
236
|
+
return negation_check(false, negation) unless same_compare_type?(value, test_against)
|
|
237
|
+
|
|
238
|
+
negation_check(yield(value, test_against), negation)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# JS +typeof+ parity for the {compare_numeric} guard: a normalized numeric is
|
|
242
|
+
# type "number"; everything else compares by whether BOTH are Numeric or
|
|
243
|
+
# NEITHER is (a String stays "string").
|
|
244
|
+
# @api private
|
|
245
|
+
def self.same_compare_type?(value, test_against)
|
|
246
|
+
value.is_a?(Numeric) == test_against.is_a?(Numeric)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# JS +_returnNegationCheck+ (+comparisons.ts:174-183+): invert when negated.
|
|
250
|
+
# @api private
|
|
251
|
+
def self.negation_check(result, negation)
|
|
252
|
+
negation ? !result : result
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private_class_method :compare_numeric, :same_compare_type?, :negation_check
|
|
256
|
+
end
|
|
257
|
+
end
|