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,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The single persistence port every manager flows through.
|
|
5
|
+
#
|
|
6
|
+
# +DataStoreManager+ wraps a duck-typed *store* (anything responding to
|
|
7
|
+
# +#get(key)+ / +#set(key, value)+) and is the ONLY object that holds a raw
|
|
8
|
+
# store reference — managers (config caching in Story 2.7, sticky bucketing in
|
|
9
|
+
# 2.11, goal dedup in 4.3) never touch a store directly. This gives the SDK
|
|
10
|
+
# one place to enforce three guarantees:
|
|
11
|
+
#
|
|
12
|
+
# 1. *Validation at wiring time.* The supplied store is duck-type-checked once,
|
|
13
|
+
# at construction. A non-conforming store is rejected with a logged error
|
|
14
|
+
# and replaced by a {Stores::MemoryStore} — wiring NEVER raises and NEVER
|
|
15
|
+
# accepts a broken store. (The JS SDK's +isValidDataStore+ checks only that
|
|
16
|
+
# +get+/+set+ are functions, with no arity enforcement; this port matches
|
|
17
|
+
# that contract exactly. Unlike JS — which leaves its data store undefined
|
|
18
|
+
# on invalid input — this Ruby port intentionally falls back to a working
|
|
19
|
+
# MemoryStore, because a Ruby process must never crash on SDK wiring errors.)
|
|
20
|
+
#
|
|
21
|
+
# 2. *Never-crash passthrough.* {#get} / {#set} rescue +StandardError+ from a
|
|
22
|
+
# user-supplied store and log it; a raising store degrades to +nil+ (get) or
|
|
23
|
+
# a no-op (set) instead of crashing the host.
|
|
24
|
+
#
|
|
25
|
+
# 3. *Atomic visitor-data merge.* {#merge_visitor_data} runs the whole
|
|
26
|
+
# read-modify-write cycle inside a manager-level mutex, so a compound
|
|
27
|
+
# "read current state, decide, write" operation is atomic by construction.
|
|
28
|
+
# Goal dedup (Story 4.3) builds its check-then-mark on this guarantee.
|
|
29
|
+
#
|
|
30
|
+
# == One store, two tenants
|
|
31
|
+
#
|
|
32
|
+
# A single store instance backs both config caching and visitor data. Keys are
|
|
33
|
+
# namespaced so the two never collide: config entries use
|
|
34
|
+
# +convert_sdk.config.{sdk_key}+ ({#config_key}) and visitor entries use
|
|
35
|
+
# +{account_id}-{project_id}-{visitor_id}+ ({#visitor_key}, byte-identical to
|
|
36
|
+
# the JS +getStoreKey+ format). The two key shapes are structurally disjoint.
|
|
37
|
+
#
|
|
38
|
+
# == StoreData
|
|
39
|
+
#
|
|
40
|
+
# Visitor data is a string-keyed hash of the JS +StoreData+ shape —
|
|
41
|
+
# +{"bucketing" => {...}, "segments" => {...}, "goals" => {...}}+ (plus
|
|
42
|
+
# +"locations"+). Everything stored is string-keyed (wire-world); no symbols
|
|
43
|
+
# appear in stored structures.
|
|
44
|
+
#
|
|
45
|
+
# == Thread safety
|
|
46
|
+
#
|
|
47
|
+
# The merge cycle is guarded by +@merge_mutex+. The default {Stores::MemoryStore}
|
|
48
|
+
# adds its own internal lock, so in-process merges are atomic. For external
|
|
49
|
+
# stores (e.g. +RedisStore+, Story 2.2) the same code path runs, but
|
|
50
|
+
# cross-process merge atomicity is store-dependent and must be provided by the
|
|
51
|
+
# backing store.
|
|
52
|
+
class DataStoreManager
|
|
53
|
+
# Methods a store must respond to (JS +isValidDataStore+ contract — presence
|
|
54
|
+
# only, no arity check).
|
|
55
|
+
REQUIRED_STORE_METHODS = %i[get set].freeze
|
|
56
|
+
|
|
57
|
+
# @return [Object] the validated backing store (the supplied store, or a
|
|
58
|
+
# {Stores::MemoryStore} fallback).
|
|
59
|
+
attr_reader :store
|
|
60
|
+
|
|
61
|
+
# @param store [Object, nil] a duck-typed store responding to +get+/+set+.
|
|
62
|
+
# +nil+ or an invalid store falls back to a new {Stores::MemoryStore}.
|
|
63
|
+
# @param log_manager [LogManager] injected logger for validation/passthrough
|
|
64
|
+
# diagnostics.
|
|
65
|
+
def initialize(log_manager:, store: nil)
|
|
66
|
+
@log_manager = log_manager
|
|
67
|
+
@store = resolve_store(store)
|
|
68
|
+
# Thread safety: guarded by @merge_mutex.
|
|
69
|
+
@merge_mutex = Thread::Mutex.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Read the value stored under +key+. A raising store is contained: the error
|
|
73
|
+
# is logged and +nil+ is returned.
|
|
74
|
+
#
|
|
75
|
+
# @param key [String]
|
|
76
|
+
# @return [Object, nil]
|
|
77
|
+
def get(key)
|
|
78
|
+
@store.get(key)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
@log_manager.error("DataStoreManager#get: store raised (#{e.message})")
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Store +value+ under +key+. A raising store is contained: the error is
|
|
85
|
+
# logged and the call is a no-op.
|
|
86
|
+
#
|
|
87
|
+
# @param key [String]
|
|
88
|
+
# @param value [Object]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def set(key, value)
|
|
91
|
+
@store.set(key, value)
|
|
92
|
+
nil
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
@log_manager.error("DataStoreManager#set: store raised (#{e.message})")
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build the visitor-data store key — byte-identical to the JS
|
|
99
|
+
# +getStoreKey+ format +`${accountId}-${projectId}-${visitorId}`+. This is
|
|
100
|
+
# the SINGLE construction site for visitor keys.
|
|
101
|
+
#
|
|
102
|
+
# @param account_id [String]
|
|
103
|
+
# @param project_id [String]
|
|
104
|
+
# @param visitor_id [String]
|
|
105
|
+
# @return [String]
|
|
106
|
+
def visitor_key(account_id, project_id, visitor_id)
|
|
107
|
+
"#{account_id}-#{project_id}-#{visitor_id}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Build the config-cache store key. SINGLE construction site for config keys.
|
|
111
|
+
#
|
|
112
|
+
# @param sdk_key [String]
|
|
113
|
+
# @return [String]
|
|
114
|
+
def config_key(sdk_key)
|
|
115
|
+
"convert_sdk.config.#{sdk_key}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Atomically read-modify-write a visitor's +StoreData+.
|
|
119
|
+
#
|
|
120
|
+
# The entire cycle — read current data, yield it to the block, deep-merge
|
|
121
|
+
# the block's returned partial, write the result — runs inside
|
|
122
|
+
# +@merge_mutex+, so it is atomic by construction. The block receives the
|
|
123
|
+
# current stored data (or +{}+ for a first write) and returns a +StoreData+
|
|
124
|
+
# partial to merge in; this lets a caller inspect current state and decide
|
|
125
|
+
# what to write atomically (the substrate for Story 4.3's check-then-mark
|
|
126
|
+
# goal dedup).
|
|
127
|
+
#
|
|
128
|
+
# Merge semantics match the JS +objectDeepMerge+: nested string-keyed hashes
|
|
129
|
+
# merge recursively, arrays union (deduped, new values first), and scalars
|
|
130
|
+
# from the partial win.
|
|
131
|
+
#
|
|
132
|
+
# @param account_id [String]
|
|
133
|
+
# @param project_id [String]
|
|
134
|
+
# @param visitor_id [String]
|
|
135
|
+
# @yieldparam current [Hash] the current stored +StoreData+ (or +{}+).
|
|
136
|
+
# @yieldreturn [Hash] the +StoreData+ partial to merge in.
|
|
137
|
+
# @return [Hash] the merged, persisted +StoreData+.
|
|
138
|
+
def merge_visitor_data(account_id, project_id, visitor_id)
|
|
139
|
+
key = visitor_key(account_id, project_id, visitor_id)
|
|
140
|
+
@merge_mutex.synchronize do
|
|
141
|
+
current = get(key) || {}
|
|
142
|
+
partial = yield(current)
|
|
143
|
+
merged = deep_merge(current, partial || {})
|
|
144
|
+
set(key, merged)
|
|
145
|
+
merged
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Validate and resolve the backing store. Invalid → logged error + a fresh
|
|
152
|
+
# MemoryStore fallback.
|
|
153
|
+
def resolve_store(store)
|
|
154
|
+
return Stores::MemoryStore.new if store.nil?
|
|
155
|
+
|
|
156
|
+
if valid_store?(store)
|
|
157
|
+
store
|
|
158
|
+
else
|
|
159
|
+
@log_manager.error("DataStoreManager#resolve_store: rejected store " \
|
|
160
|
+
"#{store.class} (must respond to get/set); using MemoryStore")
|
|
161
|
+
Stores::MemoryStore.new
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# JS +isValidDataStore+ parity: presence of +get+ and +set+, no arity check.
|
|
166
|
+
def valid_store?(store)
|
|
167
|
+
REQUIRED_STORE_METHODS.all? { |m| store.respond_to?(m) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Recursive deep merge mirroring the JS +objectDeepMerge+ contract. Arrays
|
|
171
|
+
# union (new values first, deduped); nested hashes recurse; scalars from the
|
|
172
|
+
# right-hand (new) value win.
|
|
173
|
+
def deep_merge(base, incoming)
|
|
174
|
+
base.merge(incoming) do |_key, base_val, new_val|
|
|
175
|
+
if base_val.is_a?(Array) && new_val.is_a?(Array)
|
|
176
|
+
(new_val + base_val).uniq
|
|
177
|
+
elsif base_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
178
|
+
deep_merge(base_val, new_val)
|
|
179
|
+
else
|
|
180
|
+
new_val
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sentinel"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
# Bucketing business misses, signaled as frozen singleton {Sentinel}s.
|
|
7
|
+
#
|
|
8
|
+
# Wire strings are byte-identical to the JS SDK
|
|
9
|
+
# (javascript-sdk/packages/enums/src/bucketing-error.ts). NOTE: the JS source
|
|
10
|
+
# has a typo in the constant *name* (+VARIAION_NOT_DECIDED+, missing the "T").
|
|
11
|
+
# The Ruby constant spelling is CORRECTED to {VARIATION_NOT_DECIDED}; the wire
|
|
12
|
+
# string +convert.com_variation_not_decided+ is left byte-identical to JS.
|
|
13
|
+
module BucketingError
|
|
14
|
+
# No variation could be decided for the visitor.
|
|
15
|
+
# Wire: +convert.com_variation_not_decided+ (byte-identical to JS).
|
|
16
|
+
VARIATION_NOT_DECIDED = Sentinel.new("convert.com_variation_not_decided")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Feature toggle status. Wire values byte-identical to the JS SDK
|
|
5
|
+
# (javascript-sdk/packages/enums/src/feature-status.ts).
|
|
6
|
+
module FeatureStatus
|
|
7
|
+
# The feature is on. Wire: +enabled+.
|
|
8
|
+
ENABLED = "enabled"
|
|
9
|
+
|
|
10
|
+
# The feature is off. Wire: +disabled+.
|
|
11
|
+
DISABLED = "disabled"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Recognized keys for conversion goal data (consumed by conversion tracking,
|
|
5
|
+
# Story 4.3). Wire strings byte-identical to the JS SDK
|
|
6
|
+
# (javascript-sdk/packages/enums/src/goal-data-key.ts).
|
|
7
|
+
module GoalDataKey
|
|
8
|
+
# Revenue amount. Wire: +amount+.
|
|
9
|
+
AMOUNT = "amount"
|
|
10
|
+
# Number of products. Wire: +productsCount+.
|
|
11
|
+
PRODUCTS_COUNT = "productsCount"
|
|
12
|
+
# Transaction identifier. Wire: +transactionId+.
|
|
13
|
+
TRANSACTION_ID = "transactionId"
|
|
14
|
+
# Custom dimension 1. Wire: +customDimension1+.
|
|
15
|
+
CUSTOM_DIMENSION_1 = "customDimension1"
|
|
16
|
+
# Custom dimension 2. Wire: +customDimension2+.
|
|
17
|
+
CUSTOM_DIMENSION_2 = "customDimension2"
|
|
18
|
+
# Custom dimension 3. Wire: +customDimension3+.
|
|
19
|
+
CUSTOM_DIMENSION_3 = "customDimension3"
|
|
20
|
+
# Custom dimension 4. Wire: +customDimension4+.
|
|
21
|
+
CUSTOM_DIMENSION_4 = "customDimension4"
|
|
22
|
+
# Custom dimension 5. Wire: +customDimension5+.
|
|
23
|
+
CUSTOM_DIMENSION_5 = "customDimension5"
|
|
24
|
+
|
|
25
|
+
# All recognized goal-data keys, in declaration order. Frozen array for
|
|
26
|
+
# validation use (Story 4.3).
|
|
27
|
+
ALL = [
|
|
28
|
+
AMOUNT, PRODUCTS_COUNT, TRANSACTION_ID,
|
|
29
|
+
CUSTOM_DIMENSION_1, CUSTOM_DIMENSION_2, CUSTOM_DIMENSION_3,
|
|
30
|
+
CUSTOM_DIMENSION_4, CUSTOM_DIMENSION_5
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# The two-worlds mapping (Story 4.3): the PUBLIC Ruby +track_conversion+
|
|
34
|
+
# +goal_data:+ surface accepts snake_case symbol keys; this is the SINGLE
|
|
35
|
+
# place the snake_case input is translated to the camelCase WIRE identifier.
|
|
36
|
+
# The conversion build site (DataManager#convert) consults this map to
|
|
37
|
+
# validate caller keys and emit the wire-correct +[{key, value}]+ pairs;
|
|
38
|
+
# any key absent here is unknown and rejected. Frozen so it cannot drift.
|
|
39
|
+
RUBY_KEY_MAP = {
|
|
40
|
+
amount: AMOUNT,
|
|
41
|
+
products_count: PRODUCTS_COUNT,
|
|
42
|
+
transaction_id: TRANSACTION_ID,
|
|
43
|
+
custom_dimension_1: CUSTOM_DIMENSION_1,
|
|
44
|
+
custom_dimension_2: CUSTOM_DIMENSION_2,
|
|
45
|
+
custom_dimension_3: CUSTOM_DIMENSION_3,
|
|
46
|
+
custom_dimension_4: CUSTOM_DIMENSION_4,
|
|
47
|
+
custom_dimension_5: CUSTOM_DIMENSION_5
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Translate a single caller-supplied +goal_data+ key (symbol or string,
|
|
51
|
+
# snake_case OR the camelCase wire form) to its wire identifier, or +nil+
|
|
52
|
+
# when the key is not one of the eight platform keys (caller rejects it).
|
|
53
|
+
# Accepting the wire form too keeps the surface forgiving for integrators
|
|
54
|
+
# who already know the platform identifiers.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Symbol, String] the caller key.
|
|
57
|
+
# @return [String, nil] the wire identifier, or nil when unrecognized.
|
|
58
|
+
def self.wire_key_for(key)
|
|
59
|
+
RUBY_KEY_MAP[key.to_sym] || (ALL.include?(key.to_s) ? key.to_s : nil)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Logging verbosity levels, ordered least-to-most severe. Integer values are
|
|
5
|
+
# JS-parity, verified against javascript-sdk/packages/enums/src/log-level.ts.
|
|
6
|
+
# Consumed by LogManager (Story 1.4): a message logs when its level is >= the
|
|
7
|
+
# configured threshold; +SILENT+ suppresses everything.
|
|
8
|
+
module LogLevel
|
|
9
|
+
# Finest-grained tracing.
|
|
10
|
+
TRACE = 0
|
|
11
|
+
# Debug diagnostics.
|
|
12
|
+
DEBUG = 1
|
|
13
|
+
# Informational messages.
|
|
14
|
+
INFO = 2
|
|
15
|
+
# Warnings.
|
|
16
|
+
WARN = 3
|
|
17
|
+
# Errors.
|
|
18
|
+
ERROR = 4
|
|
19
|
+
# Suppress all logging.
|
|
20
|
+
SILENT = 5
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sentinel"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
# Rule-evaluation business misses, signaled as frozen singleton {Sentinel}s.
|
|
7
|
+
#
|
|
8
|
+
# Returned by audience/rule evaluation when a decision cannot be made. Wire
|
|
9
|
+
# strings are byte-identical to the JS SDK
|
|
10
|
+
# (javascript-sdk/packages/enums/src/rule-error.ts) and appear on the wire.
|
|
11
|
+
module RuleError
|
|
12
|
+
# No data was found to evaluate the rule. Wire: +convert.com_no_data_found+.
|
|
13
|
+
NO_DATA_FOUND = Sentinel.new("convert.com_no_data_found")
|
|
14
|
+
|
|
15
|
+
# More data is required before a decision can be made.
|
|
16
|
+
# Wire: +convert.com_need_more_data+.
|
|
17
|
+
NEED_MORE_DATA = Sentinel.new("convert.com_need_more_data")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# SDK system event names, fired by EventManager across Epics 2-4. Wire strings
|
|
5
|
+
# are byte-identical to the JS SDK
|
|
6
|
+
# (javascript-sdk/packages/enums/src/system-events.ts).
|
|
7
|
+
module SystemEvents
|
|
8
|
+
# SDK is ready. Wire: +ready+.
|
|
9
|
+
READY = "ready"
|
|
10
|
+
# Remote config was updated. Wire: +config.updated+.
|
|
11
|
+
CONFIG_UPDATED = "config.updated"
|
|
12
|
+
# A bucketing decision was made. Wire: +bucketing+.
|
|
13
|
+
BUCKETING = "bucketing"
|
|
14
|
+
# A conversion was tracked. Wire: +conversion+.
|
|
15
|
+
CONVERSION = "conversion"
|
|
16
|
+
# The API request queue was released. Wire: +api.queue.released+.
|
|
17
|
+
API_QUEUE_RELEASED = "api.queue.released"
|
|
18
|
+
# Visitor segments were computed. Wire: +segments+.
|
|
19
|
+
SEGMENTS = "segments"
|
|
20
|
+
# A location was activated. Wire: +location.activated+.
|
|
21
|
+
LOCATION_ACTIVATED = "location.activated"
|
|
22
|
+
# A location was deactivated. Wire: +location.deactivated+.
|
|
23
|
+
LOCATION_DEACTIVATED = "location.deactivated"
|
|
24
|
+
# Audiences were evaluated. Wire: +audiences+.
|
|
25
|
+
AUDIENCES = "audiences"
|
|
26
|
+
# The datastore queue was released. Wire: +datastore.queue.released+.
|
|
27
|
+
DATASTORE_QUEUE_RELEASED = "datastore.queue.released"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Synchronous, thread-safe pub/sub engine for SDK lifecycle events.
|
|
5
|
+
#
|
|
6
|
+
# +EventManager+ is the single emission point for the SDK's lifecycle signals.
|
|
7
|
+
# Consumers subscribe with {#on} using the cross-SDK-consistent event names
|
|
8
|
+
# ({SystemEvents}); the SDK's internal stages fire those events with {#fire}
|
|
9
|
+
# as wiring lands in later stories (Client +ready+ in 2.5, +config.updated+
|
|
10
|
+
# per refresh in 2.7, +bucketing+ in 2.11/4.1, +conversion+ in 4.3,
|
|
11
|
+
# +api.queue.released+ in 4.2). This story delivers the engine only.
|
|
12
|
+
#
|
|
13
|
+
# == Event names are a wire-parity surface (FR57)
|
|
14
|
+
#
|
|
15
|
+
# Event names are byte-identical to the JS SDK's +SystemEvents+ strings. A
|
|
16
|
+
# {SystemEvents} constant *is* its wire string (e.g.
|
|
17
|
+
# +SystemEvents::READY == "ready"+), so +on(SystemEvents::READY)+ and
|
|
18
|
+
# +on("ready")+ register under the SAME string key. Names are normalized to
|
|
19
|
+
# their string form (+#to_s+) before they touch the registry.
|
|
20
|
+
#
|
|
21
|
+
# == Synchronous firing
|
|
22
|
+
#
|
|
23
|
+
# Events fire synchronously, in registration order, at each lifecycle stage —
|
|
24
|
+
# no event thread, no queue. A slow listener slows the SDK (documented, JS
|
|
25
|
+
# parity). The firing path never raises into its caller: a listener that
|
|
26
|
+
# raises is caught and logged, and the remaining listeners still run.
|
|
27
|
+
#
|
|
28
|
+
# == Deferred replay for late subscribers
|
|
29
|
+
#
|
|
30
|
+
# Some events (READY, CONVERSION in JS) fire with <tt>deferred: true</tt>. The
|
|
31
|
+
# first deferred emission of an event records its +{payload, err}+ so a
|
|
32
|
+
# listener that subscribes *after* the event already happened is replayed the
|
|
33
|
+
# stored value the moment it registers. This lets late subscribers observe a
|
|
34
|
+
# one-shot lifecycle signal they would otherwise have missed.
|
|
35
|
+
#
|
|
36
|
+
# == Thread safety
|
|
37
|
+
#
|
|
38
|
+
# The listener registry and the deferred store are both guarded by
|
|
39
|
+
# +@listeners_mutex+. Registration mutates the registry inside the lock.
|
|
40
|
+
# Firing takes a +dup+ snapshot of the listener list inside the lock, then
|
|
41
|
+
# iterates that snapshot OUTSIDE the lock — so a listener body (which runs
|
|
42
|
+
# unlocked) may itself call {#on} to register a new listener without
|
|
43
|
+
# deadlocking. The newly added listener is not invoked by the in-flight fire
|
|
44
|
+
# (it was not in the snapshot); it participates in subsequent fires.
|
|
45
|
+
class EventManager
|
|
46
|
+
# @param log_manager [LogManager] sink for contained listener failures and
|
|
47
|
+
# unknown-event debug traces.
|
|
48
|
+
def initialize(log_manager:)
|
|
49
|
+
@log_manager = log_manager
|
|
50
|
+
# event name (String) => Array<Proc> of listeners, registration-ordered.
|
|
51
|
+
@listeners = {}
|
|
52
|
+
# event name (String) => { payload:, err: } recorded by the first
|
|
53
|
+
# deferred fire, replayed to late subscribers.
|
|
54
|
+
@deferred = {}
|
|
55
|
+
# Thread safety: guarded by @listeners_mutex (both @listeners and @deferred).
|
|
56
|
+
@listeners_mutex = Thread::Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Subscribe to an event. Public API.
|
|
60
|
+
#
|
|
61
|
+
# Accepts a {SystemEvents} constant (which IS its string value) or any
|
|
62
|
+
# matching string; the name is normalized to its string form so both
|
|
63
|
+
# spellings register under one key. If the event was previously fired with
|
|
64
|
+
# <tt>deferred: true</tt>, the listener is invoked immediately with the
|
|
65
|
+
# stored payload/err (deferred replay).
|
|
66
|
+
#
|
|
67
|
+
# @param event [String] a {SystemEvents} value or matching string.
|
|
68
|
+
# @yieldparam payload [Object, nil] the emitted payload.
|
|
69
|
+
# @yieldparam err [Object, nil] the emitted error, or +nil+ on normal
|
|
70
|
+
# emission. Single-parameter blocks work — extra args are ignored.
|
|
71
|
+
# @return [self]
|
|
72
|
+
def on(event, &listener)
|
|
73
|
+
return self if listener.nil?
|
|
74
|
+
|
|
75
|
+
key = event.to_s
|
|
76
|
+
deferred = @listeners_mutex.synchronize do
|
|
77
|
+
(@listeners[key] ||= []) << listener
|
|
78
|
+
@deferred[key]
|
|
79
|
+
end
|
|
80
|
+
# Replay outside the lock so the listener body may itself call #on.
|
|
81
|
+
invoke(key, listener, deferred[:payload], deferred[:err]) if deferred
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Emit an event to all currently registered listeners. Internal API.
|
|
86
|
+
#
|
|
87
|
+
# @api private
|
|
88
|
+
# @param event [String] a {SystemEvents} value or matching string.
|
|
89
|
+
# @param payload [Object, nil] delivered as the listener's first argument.
|
|
90
|
+
# @param err [Object, nil] delivered as the listener's second argument
|
|
91
|
+
# (+nil+ on normal emission).
|
|
92
|
+
# @param deferred [Boolean] when true, the first such emission of this event
|
|
93
|
+
# is recorded for replay to late subscribers (see class docs).
|
|
94
|
+
# @return [void]
|
|
95
|
+
def fire(event, payload = nil, err = nil, deferred: false)
|
|
96
|
+
key = event.to_s
|
|
97
|
+
snapshot = @listeners_mutex.synchronize do
|
|
98
|
+
@deferred[key] ||= { payload: payload, err: err } if deferred
|
|
99
|
+
@listeners[key]&.dup
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if snapshot.nil? || snapshot.empty?
|
|
103
|
+
@log_manager.debug("EventManager#fire: no listeners for '#{key}'")
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Iterate the snapshot OUTSIDE the lock — listener bodies run unlocked and
|
|
108
|
+
# may re-register without deadlock.
|
|
109
|
+
snapshot.each { |listener| invoke(key, listener, payload, err) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Invoke one listener with exception containment. A raising listener is
|
|
115
|
+
# caught (StandardError only — never Exception) and logged at error level;
|
|
116
|
+
# it is never re-raised, so siblings still fire and the host never crashes.
|
|
117
|
+
def invoke(event, listener, payload, err)
|
|
118
|
+
listener.call(payload, err)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
@log_manager.error(
|
|
121
|
+
"EventManager#fire: listener for '#{event}' raised #{e.class}: #{e.message}"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Variation-selection support — the thin per-experience / across-experiences
|
|
5
|
+
# entry surface over the {DataManager} decision flow.
|
|
6
|
+
#
|
|
7
|
+
# This mirrors the JS +experience-manager.ts+ division of labor EXACTLY: the
|
|
8
|
+
# ExperienceManager owns variation SELECTION (the public-ish +select_variation+
|
|
9
|
+
# / +select_variations+ seams that {Context} drives), while the ORDERED decision
|
|
10
|
+
# FLOW — entity -> archived -> environment -> stored-bucketing -> locations ->
|
|
11
|
+
# audiences -> custom segments -> traffic allocation -> variation — lives in
|
|
12
|
+
# {DataManager#get_bucketing} (JS +data-manager.ts:227-720+). A reordered step
|
|
13
|
+
# is a parity bug; the order is owned in ONE place (DataManager) and exercised,
|
|
14
|
+
# not duplicated here.
|
|
15
|
+
#
|
|
16
|
+
# == +select_variation+ (one experience by key)
|
|
17
|
+
#
|
|
18
|
+
# Delegates straight to {DataManager#get_bucketing}, returning a frozen
|
|
19
|
+
# {BucketedVariation} on a hit or a {Sentinel} ({RuleError}/{BucketingError}) on
|
|
20
|
+
# a miss — JS +selectVariation+ (+experience-manager.ts:110-116+).
|
|
21
|
+
#
|
|
22
|
+
# == +select_variations+ (all experiences)
|
|
23
|
+
#
|
|
24
|
+
# Maps {DataManager#get_bucketing} over EVERY configured experience and FILTERS
|
|
25
|
+
# OUT every non-decision: +nil+, {RuleError}, and {BucketingError} sentinels.
|
|
26
|
+
# This is the JS +selectVariations+ contract (+experience-manager.ts:159-168+):
|
|
27
|
+
# the across-all-experiences call returns ONLY the variations a visitor was
|
|
28
|
+
# actually bucketed into; misses never appear in the list (FR16 return shape).
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
class ExperienceManager
|
|
32
|
+
# @param data_manager [DataManager] the decision-flow owner (holds the config
|
|
33
|
+
# snapshot, the bucketing/rule collaborators, and the visitor store seam).
|
|
34
|
+
# @param log_manager [LogManager, nil] optional debug logger.
|
|
35
|
+
def initialize(data_manager:, log_manager: nil)
|
|
36
|
+
@data_manager = data_manager
|
|
37
|
+
@log_manager = log_manager
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Decide one experience for a visitor by experience key.
|
|
41
|
+
#
|
|
42
|
+
# @param visitor_id [String] the visitor identifier.
|
|
43
|
+
# @param experience_key [String] the experience +key+ to decide.
|
|
44
|
+
# @param attributes [Hash] bucketing attributes — +:visitor_properties+
|
|
45
|
+
# (audiences), +:location_properties+ (locations/site_area), +:environment+,
|
|
46
|
+
# +:update_visitor_properties+.
|
|
47
|
+
# @return [BucketedVariation, Sentinel] a frozen variation, or a
|
|
48
|
+
# {RuleError}/{BucketingError} sentinel on a miss.
|
|
49
|
+
def select_variation(visitor_id, experience_key, attributes = {})
|
|
50
|
+
@data_manager.get_bucketing(visitor_id, experience_key, attributes)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Decide ALL configured experiences for a visitor, returning only the
|
|
54
|
+
# successful bucketed variations (misses filtered — JS parity).
|
|
55
|
+
#
|
|
56
|
+
# @param visitor_id [String] the visitor identifier.
|
|
57
|
+
# @param attributes [Hash] bucketing attributes (see {#select_variation}).
|
|
58
|
+
# @return [Array<BucketedVariation>] the frozen variations the visitor was
|
|
59
|
+
# bucketed into (sentinels and nils excluded).
|
|
60
|
+
def select_variations(visitor_id, attributes = {})
|
|
61
|
+
@data_manager.experiences.filter_map do |experience|
|
|
62
|
+
next unless experience.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
result = @data_manager.get_bucketing(visitor_id, experience["key"], attributes)
|
|
65
|
+
result if result.is_a?(BucketedVariation)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|