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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConvertSdk
6
+ module Stores
7
+ # A first-party, in-tree store adapter backed by Redis — the cross-process
8
+ # answer to {MemoryStore}'s per-process limitation.
9
+ #
10
+ # == Why Redis (FR49)
11
+ #
12
+ # {MemoryStore} keeps state in a single process. Puma clusters, Sidekiq
13
+ # worker fleets, and Lambda invocations each run in separate processes, so
14
+ # sticky bucketing (Story 2.11) and goal deduplication (Story 4.3) that
15
+ # round-trip through a +MemoryStore+ are inconsistent across the fleet.
16
+ # +RedisStore+ shares that state through a Redis instance, giving every
17
+ # process the same view.
18
+ #
19
+ # == Zero gemspec footprint
20
+ #
21
+ # The +redis+ gem is the *user's* dependency, never the SDK's: it is NOT a
22
+ # gemspec runtime dependency and is +require+-d *lazily* inside {#initialize}
23
+ # — and only when a client is built from connection options. Requiring this
24
+ # file (which +lib/convert_sdk.rb+ does unconditionally) therefore never
25
+ # pulls in +redis+, so +require "convert_sdk"+ stays green for users who do
26
+ # not install it. If a caller asks +RedisStore+ to build its own client
27
+ # without +redis+ installed, instantiation raises an actionable error naming
28
+ # the gem to add — a wiring-time programmer error, sanctioned in the same
29
+ # class as +ConvertSdk.create+'s argument validation, NOT a business path.
30
+ #
31
+ # == Construction
32
+ #
33
+ # # Preferred: inject an existing client (connection reuse / pooling).
34
+ # # No `require "redis"`, no `Redis.new` — works even where the adapter
35
+ # # file is loaded without the gem present.
36
+ # store = ConvertSdk::Stores::RedisStore.new(redis: Redis.new(url: ...))
37
+ #
38
+ # # Or pass connection options; the adapter lazily requires `redis` and
39
+ # # constructs the client itself.
40
+ # store = ConvertSdk::Stores::RedisStore.new(url: "redis://localhost:6379/0")
41
+ #
42
+ # An optional +key_prefix+ namespaces every key (default +"convert:"+) so the
43
+ # SDK's keys do not collide with other tenants of the same Redis database.
44
+ #
45
+ # == Thin adapter — resilience lives upstream
46
+ #
47
+ # This adapter is serialization + connection only. It does NOT rescue Redis
48
+ # client exceptions: {DataStoreManager} (Story 2.1) already wraps every
49
+ # +get+/+set+ in a rescue-log passthrough, degrading a raising store to
50
+ # +nil+/no-op instead of crashing the host. Duplicating that rescue here
51
+ # would swallow errors the manager is responsible for logging.
52
+ #
53
+ # == Cross-process consistency caveat
54
+ #
55
+ # Visitor-data merges are a read-modify-write. In-process that sequence is
56
+ # atomic under {DataStoreManager}'s mutex, but across processes sharing one
57
+ # Redis there is no such lock: concurrent writers race and the last write
58
+ # wins. This matches the JS SDK contract. The SDK does NOT use Lua scripts
59
+ # or +WATCH+/+MULTI+ to close that race — that is deliberately out of scope.
60
+ #
61
+ # Sidekiq / Lambda deployment guidance: see the Epic 5 documentation.
62
+ class RedisStore
63
+ # Default namespace prepended to every key written to Redis.
64
+ DEFAULT_KEY_PREFIX = "convert:"
65
+
66
+ # @param redis [Object, nil] an existing redis-rb-compatible client
67
+ # responding to +#get+/+#set+. When supplied, +redis+ is NOT required and
68
+ # no new client is constructed (preferred — enables connection reuse).
69
+ # @param key_prefix [String] namespace prepended to every key
70
+ # (default +"convert:"+).
71
+ # @param options [Hash] connection options (e.g. +url:+) forwarded to
72
+ # +Redis.new+ when no +redis:+ client is injected. Triggers the lazy
73
+ # +require "redis"+.
74
+ # @raise [LoadError] re-raised as an actionable error when +redis:+ is
75
+ # omitted and the +redis+ gem is not installed.
76
+ def initialize(redis: nil, key_prefix: DEFAULT_KEY_PREFIX, **options)
77
+ @key_prefix = key_prefix
78
+ @client = redis || build_client(options)
79
+ end
80
+
81
+ # Read and deserialize the value stored under +key+.
82
+ #
83
+ # @param key [String] the (unprefixed) lookup key.
84
+ # @return [Object, nil] the JSON-parsed value (string-keyed hashes,
85
+ # numbers, arrays, booleans), or +nil+ when the key is absent.
86
+ def get(key)
87
+ raw = @client.get(namespaced(key))
88
+ raw.nil? ? nil : JSON.parse(raw)
89
+ end
90
+
91
+ # Serialize +value+ to JSON and store it under +key+, overwriting any
92
+ # existing value.
93
+ #
94
+ # @param key [String] the (unprefixed) storage key.
95
+ # @param value [Object] a JSON-serializable value (StoreData shape).
96
+ # @return [Object] the client's +set+ return value.
97
+ def set(key, value)
98
+ @client.set(namespaced(key), JSON.generate(value))
99
+ end
100
+
101
+ private
102
+
103
+ # Lazily require the +redis+ gem and construct a client from +options+.
104
+ # Called ONLY when no client was injected; this is the single site that
105
+ # depends on the gem being installed.
106
+ #
107
+ # @param options [Hash] connection options forwarded to +Redis.new+.
108
+ # @return [Object] a new redis-rb client.
109
+ # @raise [LoadError] with an actionable message when the gem is absent.
110
+ def build_client(options)
111
+ require "redis"
112
+ Redis.new(**options)
113
+ rescue LoadError
114
+ raise LoadError,
115
+ "RedisStore requires the 'redis' gem — add `gem 'redis'` to your Gemfile " \
116
+ "(or inject an existing client via `redis:`)."
117
+ end
118
+
119
+ # @param key [String] the unprefixed key.
120
+ # @return [String] the key with the configured namespace prepended.
121
+ def namespaced(key)
122
+ "#{@key_prefix}#{key}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The gem version string (semantic version).
5
+ #
6
+ # This is a DEV PLACEHOLDER. The real version is written here at release time
7
+ # by the semantic-release `@semantic-release/exec` prepareCmd (release.config.mjs),
8
+ # as an UNCOMMITTED working-tree edit — the gem builds carrying the computed
9
+ # version, but `main` never receives a version-bump commit. The next release
10
+ # derives its version from this run's git tag, not from this file (FR66).
11
+ # Mirrors the Android SDK's `0.0.0` placeholder in gradle/libs.versions.toml.
12
+ # @return [String]
13
+ VERSION = "1.0.0"
14
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The per-visitor event queue — the in-memory buffer between the decision flow
5
+ # (which enqueues bucketing/conversion events) and {ApiManager} (which drains
6
+ # and POSTs them in the Convert wire format).
7
+ #
8
+ # == Per-visitor merge (structural invariant, FR36)
9
+ #
10
+ # The queue holds ONE entry per visitor — a string-keyed wire-shaped hash
11
+ # +{"visitorId" => id, "segments" => {...}?, "events" => [...]}+. Enqueuing an
12
+ # event for a visitor already in the queue APPENDS to that visitor's +events+
13
+ # array; it never adds a duplicate visitor entry and never flattens to a bare
14
+ # event list. The platform attributes events by walking +visitors[].events+, so
15
+ # flattening or duplicating corrupts report attribution. The structure itself
16
+ # enforces the invariant — there is no public path that bypasses the merge.
17
+ # (JS parity: +api-manager.ts:117-144+; PHP +VisitorsQueue.php:64-70+.)
18
+ #
19
+ # +segments+ ride on the visitor entry and are captured ONLY when the entry is
20
+ # first created (omitted entirely when none are supplied) — a later enqueue for
21
+ # the same visitor never overwrites them (JS +if (segments) visitor.segments = …+).
22
+ #
23
+ # == Bounded memory (FR39/NFR10)
24
+ #
25
+ # The queue is bounded at {MAX_EVENTS} EVENTS (events, not visitors). On
26
+ # overflow the OLDEST event is dropped — and the visitor entry is removed once
27
+ # its last event is gone — with a +warn+ log per drop. An endpoint outage can
28
+ # never grow host memory without bound; dropping the oldest (not the newest)
29
+ # keeps the most recent traffic. (Optimizely +DEFAULT_QUEUE_CAPACITY = 1000+
30
+ # precedent; research frozen register #7.)
31
+ #
32
+ # == Thread safety (NFR2/NFR13)
33
+ #
34
+ # Every operation is serialized by +@queue_mutex+. {#enqueue} is pure in-memory
35
+ # and never blocks on I/O, so the calling request thread is never held on the
36
+ # network. {#drain!} is an atomic drain-and-swap inside the lock returning the
37
+ # drained visitors array — {ApiManager} builds the payload and POSTs OUTSIDE the
38
+ # lock, so network I/O never holds the queue. The drained array is re-enqueueable
39
+ # without violating the per-visitor merge (the retention path Story 4.2 needs).
40
+ #
41
+ # @api private
42
+ class VisitorsQueue
43
+ # The hard upper bound on buffered events (events, not visitors). Research
44
+ # frozen register #7; the JS SDK has no equivalent memory cap.
45
+ MAX_EVENTS = 1000
46
+
47
+ # @param log_manager [LogManager] the redacting logging surface (warn on overflow).
48
+ def initialize(log_manager:)
49
+ @log_manager = log_manager
50
+ # Thread safety: guarded by @queue_mutex. @items is the ordered list of
51
+ # per-visitor entries; @size is the total event count (the cap dimension).
52
+ @queue_mutex = Thread::Mutex.new
53
+ @items = [] #: Array[Hash[String, untyped]]
54
+ @size = 0
55
+ end
56
+
57
+ # Enqueue one wire-shaped event for +visitor_id+, merging into the visitor's
58
+ # existing entry (append) or creating a new one. Pure in-memory — never blocks
59
+ # on I/O. On overflow past {MAX_EVENTS} the oldest event is dropped (+warn+).
60
+ #
61
+ # @param visitor_id [String] the visitor the event belongs to.
62
+ # @param event [Hash{String=>Object}] a wire-shaped (string-keyed camelCase)
63
+ # event hash, e.g. +{"eventType"=>"bucketing", "data"=>{...}}+.
64
+ # @param segments [Hash{String=>Object}, nil] the visitor's report-segments,
65
+ # attached ONLY when this enqueue first creates the visitor's entry.
66
+ # @return [void]
67
+ def enqueue(visitor_id, event, segments: nil)
68
+ @queue_mutex.synchronize do
69
+ entry = @items.find { |item| item["visitorId"] == visitor_id }
70
+ if entry
71
+ entry["events"] << event
72
+ else
73
+ entry = { "visitorId" => visitor_id, "events" => [event] } #: Hash[String, untyped]
74
+ entry["segments"] = segments unless segments.nil?
75
+ @items << entry
76
+ end
77
+ @size += 1
78
+ trim_to_cap
79
+ end
80
+ end
81
+
82
+ # Atomically drain the queue: swap out the current per-visitor entries and
83
+ # reset to empty inside the lock, returning the drained array. The caller
84
+ # (ApiManager) builds the payload and POSTs OUTSIDE the lock.
85
+ #
86
+ # @return [Array<Hash{String=>Object}>] the drained per-visitor entries
87
+ # (empty when nothing was queued); re-enqueueable verbatim.
88
+ def drain!
89
+ @queue_mutex.synchronize do
90
+ drained = @items
91
+ @items = []
92
+ @size = 0
93
+ drained
94
+ end
95
+ end
96
+
97
+ # Re-enqueue previously drained per-visitor entries after a failed delivery
98
+ # (Story 4.2 failure retention), PRESERVING the per-visitor merge. Runs as one
99
+ # atomic compound operation inside +@queue_mutex+.
100
+ #
101
+ # The drained events are OLDER than anything the queue received during the
102
+ # failed POST, so they are placed BEFORE newer events: a drained visitor that
103
+ # already has a live entry (new events arrived for it mid-failure) has its
104
+ # drained events PREPENDED to that entry — never a duplicate visitor entry;
105
+ # a drained visitor with no live entry is inserted at the FRONT of the queue
106
+ # (its events are the oldest). Segments ride from whichever entry has them
107
+ # (the live entry wins; otherwise the drained entry's segments are adopted).
108
+ #
109
+ # Re-enqueued events count toward {MAX_EVENTS}: a sustained outage that keeps
110
+ # requeuing drops the OLDEST events (+warn+ per drop), bounding host memory
111
+ # without bound (NFR10).
112
+ #
113
+ # @param visitors [Array<Hash{String=>Object}>] drained per-visitor entries
114
+ # (as returned by {#drain!}); an empty array is a no-op.
115
+ # @return [void]
116
+ def requeue(visitors)
117
+ return if visitors.empty?
118
+
119
+ @queue_mutex.synchronize do
120
+ # Walk the drained entries in reverse so that successive front-inserts
121
+ # preserve their original relative order at the head of the queue.
122
+ visitors.reverse_each { |drained| merge_drained(drained) }
123
+ trim_to_cap
124
+ end
125
+ end
126
+
127
+ # @return [Integer] the total number of buffered EVENTS (not visitors).
128
+ def size
129
+ @queue_mutex.synchronize { @size }
130
+ end
131
+
132
+ # Atomically empty the queue WITHOUT returning the entries (Story 4.4 child
133
+ # queue-ownership clear). A forked child inherits a COPY of the parent's
134
+ # queued events; clearing the child's copy ensures the child never
135
+ # double-delivers the parent's events (the parent's timer still runs there
136
+ # and delivers them). Distinct from {#drain!} (which allocates and returns
137
+ # the entries for delivery) — this just discards. Idempotent.
138
+ # @return [void]
139
+ def clear
140
+ @queue_mutex.synchronize do
141
+ @items = []
142
+ @size = 0
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ # Merge ONE drained per-visitor entry back into @items, preserving the
149
+ # per-visitor merge. Caller holds @queue_mutex.
150
+ #
151
+ # When a live entry exists for the visitor, the drained (older) events are
152
+ # PREPENDED to it and the live entry adopts the drained segments only if it
153
+ # has none. Otherwise the drained entry is inserted at the FRONT of the queue
154
+ # (its events are older than all live traffic). @size grows by the drained
155
+ # event count; {#trim_to_cap} (run by the caller after all merges) bounds it.
156
+ def merge_drained(drained)
157
+ visitor_id = drained["visitorId"]
158
+ drained_events = drained["events"]
159
+ existing = @items.find { |item| item["visitorId"] == visitor_id }
160
+ if existing
161
+ existing["events"].unshift(*drained_events)
162
+ existing["segments"] = drained["segments"] if !existing.key?("segments") && drained.key?("segments")
163
+ else
164
+ @items.unshift(drained)
165
+ end
166
+ @size += drained_events.size
167
+ end
168
+
169
+ # Drop oldest events until the event count is within {MAX_EVENTS}. Removes a
170
+ # visitor entry once its last event is gone. Caller holds @queue_mutex.
171
+ # Emits ONE aggregated warn after the loop (never one per dropped event) to
172
+ # avoid a warn burst when +requeue+ overshoots the cap by a full drained batch
173
+ # during a sustained outage.
174
+ def trim_to_cap
175
+ dropped_count = 0
176
+ while @size > MAX_EVENTS
177
+ oldest = @items.first
178
+ break if oldest.nil?
179
+
180
+ oldest["events"].shift
181
+ @items.shift if oldest["events"].empty?
182
+ @size -= 1
183
+ dropped_count += 1
184
+ end
185
+ return if dropped_count.zero?
186
+
187
+ @log_manager.warn("VisitorsQueue#trim_to_cap: queue full, dropped #{dropped_count} oldest event(s)")
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "convert_sdk/version"
4
+ require_relative "convert_sdk/murmur_hash3"
5
+ require_relative "convert_sdk/sentinel"
6
+ require_relative "convert_sdk/enums/rule_error"
7
+ require_relative "convert_sdk/enums/bucketing_error"
8
+ require_relative "convert_sdk/enums/feature_status"
9
+ require_relative "convert_sdk/enums/log_level"
10
+ require_relative "convert_sdk/enums/system_events"
11
+ require_relative "convert_sdk/enums/goal_data_key"
12
+ require_relative "convert_sdk/bucketed_variation"
13
+ require_relative "convert_sdk/bucketed_feature"
14
+ require_relative "convert_sdk/redactor"
15
+ require_relative "convert_sdk/log_manager"
16
+ require_relative "convert_sdk/config_validator"
17
+ require_relative "convert_sdk/config"
18
+ require_relative "convert_sdk/bucketing_manager"
19
+ require_relative "convert_sdk/comparisons"
20
+ require_relative "convert_sdk/rule_manager"
21
+ require_relative "convert_sdk/http_client"
22
+ require_relative "convert_sdk/stores/memory_store"
23
+ require_relative "convert_sdk/stores/redis_store"
24
+ require_relative "convert_sdk/data_store_manager"
25
+ require_relative "convert_sdk/event_manager"
26
+ require_relative "convert_sdk/fork_guard"
27
+ require_relative "convert_sdk/background_timer"
28
+ require_relative "convert_sdk/data_manager"
29
+ require_relative "convert_sdk/visitors_queue"
30
+ require_relative "convert_sdk/api_manager"
31
+ require_relative "convert_sdk/experience_manager"
32
+ require_relative "convert_sdk/feature_manager"
33
+ require_relative "convert_sdk/segments_manager"
34
+ require_relative "convert_sdk/context"
35
+ require_relative "convert_sdk/client"
36
+
37
+ # Install the SDK's only global mutation — the Process._fork prepend — at load
38
+ # (it must exist before any fork; installing it is cheap and thread-free, so it
39
+ # respects the NFR4 zero-threads-until-use rule, which concerns THREADS, not the
40
+ # hook). A no-op on JRuby by construction.
41
+ ConvertSdk::ForkGuard.install!
42
+
43
+ # The Convert Experiences full-stack SDK for Ruby.
44
+ #
45
+ # {ConvertSdk.create} is THE public entry point (frozen API name): it builds the
46
+ # validated {Config}, wires the managers, and returns a ready-to-use {Client}.
47
+ module ConvertSdk
48
+ # The default config-cache TTL in seconds, used by the timer-off (Lambda/CLI)
49
+ # decision-time staleness check when +data_refresh_interval+ is +nil+
50
+ # (timer-off ≠ TTL-off). 300s converges on the same cadence the background
51
+ # timer uses, on demand. A Ruby-SDK design constant (PHP on-demand TTL
52
+ # semantics) — the JS SDK has no timer-off TTL concept. See Story 2.7.
53
+ DEFAULT_CONFIG_TTL = 300
54
+
55
+ # The SDK's base error type. Note the SDK has NO custom exception hierarchy for
56
+ # runtime/infra failures (Decision 3 — it degrades gracefully with cached
57
+ # config / sentinels); misconfiguration surfaces as a plain +ArgumentError+
58
+ # from {Config}. This type exists only as a namespace anchor.
59
+ class Error < StandardError; end
60
+
61
+ # Whether {Client} registers its PID-guarded +at_exit+ flush handler at
62
+ # construction (Story 4.4 AC#5). Always +true+ in production. The TEST HARNESS
63
+ # alone flips this off (a +spec/support+ hook) so unit specs that build clients
64
+ # do NOT register live +at_exit+ handlers — which would fire +flush+ during
65
+ # RSpec suite teardown. The handler BODY is unit-tested directly via
66
+ # +Client#run_at_exit_flush+; the live-registration path is proven end-to-end
67
+ # in a SUBPROCESS in spec/integration/fork_safety_spec.rb.
68
+ # @return [Boolean]
69
+ def self.at_exit_registration_enabled?
70
+ @at_exit_registration_enabled = true unless defined?(@at_exit_registration_enabled)
71
+ @at_exit_registration_enabled
72
+ end
73
+
74
+ # Set the {at_exit_registration_enabled?} flag (test harness only).
75
+ # @param value [Boolean]
76
+ # @return [Boolean]
77
+ def self.at_exit_registration_enabled=(value)
78
+ @at_exit_registration_enabled = value
79
+ end
80
+
81
+ # Build an SDK client from an SDK key (live config fetch) or a pre-fetched
82
+ # +data:+ object (direct data mode). THE public entry point.
83
+ #
84
+ # Wiring order: a {LogManager} is built first, then {Config} (which registers
85
+ # any +sdk_key+ / +sdk_key_secret+ with the manager's Redactor before any log
86
+ # line can carry them and raises +ArgumentError+ on misconfiguration — the
87
+ # SDK's only raising surface), then the {HttpClient}, {DataStoreManager},
88
+ # {EventManager}, and {DataManager} ports, then the {Client} (which fetches /
89
+ # installs config and fires +ready+). No background threads are started here
90
+ # (NFR4 — lazy start; the refresh / flush timers are wired by their own
91
+ # stories).
92
+ #
93
+ # @param sdk_key [String, nil] the account/project SDK key (fetch mode).
94
+ # @param data [Hash, nil] a pre-fetched config object (direct data mode); when
95
+ # supplied, no fetch occurs.
96
+ # @param store [Object, nil] an optional duck-typed data store (get/set);
97
+ # defaults to an in-memory store.
98
+ # @param clock [#call, nil] an optional monotonic time source (seconds) for
99
+ # the config-cache TTL math (Story 2.7); defaults to the SDK's monotonic
100
+ # clock. Injectable so tests control staleness without real waits. NOT a
101
+ # {Config} option — extracted here before validation.
102
+ # @param sink [Object, nil] an optional initial log sink (anything responding
103
+ # to debug/info/warn/error). Forwarded to the internally-built {LogManager}
104
+ # so the FULL lifecycle — including the construction-time config fetch
105
+ # (+HttpClient#request+ debug line, which carries the sdk_key in the config
106
+ # URL) — is observable through the public entry point. Without this seam a
107
+ # host could only attach a sink AFTER {create}, missing every init-time line
108
+ # (and therefore the init-time redaction proof). NOT a {Config} option —
109
+ # extracted here before validation (like +clock+). Invalid sinks are rejected
110
+ # by {LogManager#add_sink}, not raised.
111
+ # @param options [Hash{Symbol=>Object}] any other {Config::DEFAULTS} option
112
+ # (+sdk_key_secret+, +environment+, +log_level+, timeouts, …).
113
+ # @raise [ArgumentError] on misconfiguration (missing sdk_key+data, bad types,
114
+ # unknown option) — the only exception {create} lets escape.
115
+ # @return [Client] the wired SDK client.
116
+ def self.create(sdk_key: nil, data: nil, store: nil, clock: nil, sink: nil, **options)
117
+ config_options = options.merge(sdk_key: sdk_key, data: data)
118
+ log_manager = LogManager.new(
119
+ level: options.fetch(:log_level, Config::DEFAULTS[:log_level]), sink: sink
120
+ )
121
+ # Wire the ForkGuard re-arm logger (Story 2.7) so fork-detection debug lines
122
+ # flow through the redacting LogManager. nil-safe before wiring.
123
+ ForkGuard.logger = log_manager
124
+ config = Config.new(log_manager: log_manager, **config_options)
125
+
126
+ Client.new(config: config, log_manager: log_manager, **build_managers(config, log_manager, store, clock))
127
+ end
128
+
129
+ # Build the full collaborator graph wired around a validated {Config}: the HTTP
130
+ # port, the store/event ports, the Story 2.9/2.10 decision engines, the
131
+ # {DataManager} (decision flow), the {ApiManager} (delivery), and the per-context
132
+ # decisioning surfaces (Story 2.11 / 3.1 / 3.2). All thread-free (NFR4 — no
133
+ # timers start here). Returned as the keyword map {Client#initialize} consumes.
134
+ # @api private
135
+ def self.build_managers(config, log_manager, store, clock)
136
+ http_client = HttpClient.new(
137
+ log_manager: log_manager, open_timeout: config.open_timeout, read_timeout: config.read_timeout
138
+ )
139
+ data_store_manager = DataStoreManager.new(log_manager: log_manager, store: store)
140
+ event_manager = EventManager.new(log_manager: log_manager)
141
+ # The pure-math decision engines (Story 2.9 / 2.10) — thread-free, config-bound.
142
+ bucketing_manager = BucketingManager.new(config: config, log_manager: log_manager)
143
+ rule_manager = RuleManager.new(config: config, comparisons: Comparisons, log_manager: log_manager)
144
+ # The DataManager owns the ordered decision flow; it needs the two engines to
145
+ # bucket and walk rules (without them it is config-read-only — the 2.5/2.7 use).
146
+ data_manager = build_data_manager(
147
+ config, log_manager, data_store_manager, clock, bucketing_manager, rule_manager
148
+ )
149
+ api_manager = build_api_manager(config, log_manager, http_client, event_manager, data_manager)
150
+ decisioning = build_decisioning_managers(log_manager, data_store_manager, data_manager, rule_manager)
151
+ {
152
+ http_client: http_client, data_store_manager: data_store_manager,
153
+ event_manager: event_manager, data_manager: data_manager, api_manager: api_manager,
154
+ experience_manager: decisioning[:experience_manager],
155
+ feature_manager: decisioning[:feature_manager],
156
+ segments_manager: decisioning[:segments_manager]
157
+ }
158
+ end
159
+ private_class_method :build_managers
160
+
161
+ # Build the per-context decisioning surfaces (Story 2.11 / 3.1 / 3.2) as a
162
+ # thread-free trio (NFR4 — no timers start here). SegmentsManager reuses the
163
+ # rule engine and resolves the store-key halves from the DataManager readers
164
+ # (its account/project resolvers).
165
+ # @api private
166
+ def self.build_decisioning_managers(log_manager, data_store_manager, data_manager, rule_manager)
167
+ {
168
+ experience_manager: ExperienceManager.new(data_manager: data_manager, log_manager: log_manager),
169
+ feature_manager: FeatureManager.new(data_manager: data_manager, log_manager: log_manager),
170
+ segments_manager: SegmentsManager.new(
171
+ data_manager: data_manager, data_store_manager: data_store_manager,
172
+ account_resolver: -> { data_manager.account_id }, project_resolver: -> { data_manager.project_id },
173
+ rule_manager: rule_manager, log_manager: log_manager
174
+ )
175
+ }
176
+ end
177
+ private_class_method :build_decisioning_managers
178
+
179
+ # Build the outbound delivery surface (Story 4.1): the {ApiManager} owns the
180
+ # per-visitor event queue and the wire-payload builder. No thread is started
181
+ # here (NFR4 — the background flush timer lands in Story 4.2); construction is
182
+ # thread-free.
183
+ # @api private
184
+ def self.build_api_manager(config, log_manager, http_client, event_manager, data_manager)
185
+ ApiManager.new(
186
+ config: config, data_manager: data_manager, http_client: http_client,
187
+ event_manager: event_manager, log_manager: log_manager
188
+ )
189
+ end
190
+ private_class_method :build_api_manager
191
+
192
+ # Build the {DataManager} wired with the Story 2.7 config-cache surface AND the
193
+ # Story 2.9/2.10 decision engines: the cache lives under
194
+ # +convert_sdk.config.{sdkKey}+ (the DataManager writes through on every install
195
+ # and runs the timer-off TTL check against it). A nil sdk_key (direct-data mode)
196
+ # leaves the cache key nil, so no cache write happens. The +clock+ (monotonic
197
+ # TTL source) is injected only when supplied. The +bucketing_manager+ /
198
+ # +rule_manager+ make the ordered decision flow operative (without them the
199
+ # DataManager is config-read-only). The account/project resolvers are left to
200
+ # the DataManager's own readers (its constructor defaults to +#account_id+ /
201
+ # +#project_id+) — the live config IS the source of those store-key halves here.
202
+ # @api private
203
+ def self.build_data_manager(config, log_manager, data_store_manager, clock,
204
+ bucketing_manager, rule_manager)
205
+ config_key = config.sdk_key.nil? ? nil : data_store_manager.config_key(config.sdk_key)
206
+ clock_option = clock.nil? ? {} : { clock: clock } #: Hash[Symbol, ^() -> Float]
207
+ DataManager.new(
208
+ log_manager: log_manager,
209
+ data_store_manager: data_store_manager,
210
+ config_key: config_key,
211
+ ttl: config.data_refresh_interval,
212
+ bucketing_manager: bucketing_manager,
213
+ rule_manager: rule_manager,
214
+ **clock_option
215
+ )
216
+ end
217
+ private_class_method :build_data_manager
218
+ end
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # check-generated-rbs-header.sh — PR-blocking guard (qs-03 / B5).
3
+ #
4
+ # Mirrors the android-sdk "Enforce generated-file header (OpenAPI types)" guard
5
+ # (android-sdk/.github/workflows/ci.yml:51). Every .rbs file under
6
+ # sig/convert_sdk/config/generated/ MUST start with the generated-marker
7
+ # comment. Files that lack the header are either hand-edited or mistakenly
8
+ # added to the directory — both are PR blockers. Regenerate via the backend
9
+ # serving workflow and re-sync; never edit generated files in place.
10
+ #
11
+ # Usage: ./scripts/check-generated-rbs-header.sh
12
+ # Run from the ruby-sdk repo root. Exits 0 when all files carry the marker;
13
+ # exits 1 listing every offending file.
14
+
15
+ set -euo pipefail
16
+
17
+ GEN_DIR="sig/convert_sdk/config/generated"
18
+ MARKER="AUTO-GENERATED FROM backend apiDoc/serving"
19
+
20
+ if [ ! -d "$GEN_DIR" ]; then
21
+ echo "ERROR: generated directory not found: $GEN_DIR"
22
+ echo " Expected the directory to exist after Task B1 (qs-03)."
23
+ exit 1
24
+ fi
25
+
26
+ missing=0
27
+ while IFS= read -r -d '' file; do
28
+ if ! head -1 "$file" | grep -q "$MARKER"; then
29
+ echo "ERROR: $file is missing the auto-generated header."
30
+ echo " Expected line 1 to contain: $MARKER"
31
+ echo " Regenerate via 'yarn generateRubyRbs' in backend/apiDoc/serving"
32
+ echo " and re-sync per sig/convert_sdk/config/generated/. Do not hand-edit."
33
+ missing=1
34
+ fi
35
+ done < <(find "$GEN_DIR" -name '*.rbs' -print0)
36
+
37
+ if [ "$missing" -ne 0 ]; then
38
+ exit 1
39
+ fi
40
+
41
+ echo "OK: every .rbs file under $GEN_DIR carries the auto-generated header."