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,618 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The per-visitor public surface — THE object an integrator holds for the
|
|
5
|
+
# lifetime of one web request or background job.
|
|
6
|
+
#
|
|
7
|
+
# A +Context+ is created by {Client#create_context} and binds together one
|
|
8
|
+
# visitor (its id + normalised attributes) and the SDK's shared, injected
|
|
9
|
+
# managers (config, store, events, logging). It is deliberately a *stable
|
|
10
|
+
# shell*: the decisioning methods (+run_experience(s)+, +run_feature(s)+,
|
|
11
|
+
# +run_custom_segments+, +track_conversion+) attach to this class in later
|
|
12
|
+
# stories — this story builds creation, attribute normalisation, property
|
|
13
|
+
# updates, and the two config/visitor-data lookups.
|
|
14
|
+
#
|
|
15
|
+
# == Deep-stringify at the public boundary (FR11)
|
|
16
|
+
#
|
|
17
|
+
# Ruby integrators write symbol keys (+{ country: "US" }+); Rails params arrive
|
|
18
|
+
# string-keyed (+{ "country" => "US" }+). Both must behave identically, so
|
|
19
|
+
# EVERY attribute hash crossing the public boundary (the constructor and
|
|
20
|
+
# {#update_visitor_properties}) is recursively *deep-stringified* ONCE, here —
|
|
21
|
+
# symbol keys become strings through nested hashes and arrays-of-hashes. The
|
|
22
|
+
# internals (and everything written to the store, which is wire-world) then
|
|
23
|
+
# operate EXCLUSIVELY on string keys. Values are never coerced — only keys.
|
|
24
|
+
# (This normalisation has no JS parallel; JS has no symbol-as-hash-key idiom.)
|
|
25
|
+
#
|
|
26
|
+
# == Independence (FR12)
|
|
27
|
+
#
|
|
28
|
+
# Each {Client#create_context} call returns a NEW, independent +Context+. Two
|
|
29
|
+
# contexts for DIFFERENT visitor ids share NO in-memory state — a property
|
|
30
|
+
# update on one never bleeds into the other. Two contexts for the SAME visitor
|
|
31
|
+
# id legitimately share the visitor's +StoreData+ THROUGH the store (that is
|
|
32
|
+
# stickiness, not contamination): in-memory attributes stay per-instance, but
|
|
33
|
+
# persisted properties round-trip via the shared store.
|
|
34
|
+
#
|
|
35
|
+
# == Visitor store key
|
|
36
|
+
#
|
|
37
|
+
# All persisted visitor data lives under the +{account_id}-{project_id}-{visitor_id}+
|
|
38
|
+
# key built by the single 2.1 key builder ({DataStoreManager#visitor_key}); the
|
|
39
|
+
# account / project halves come from the {DataManager} readers. All stored
|
|
40
|
+
# visitor data is string-keyed.
|
|
41
|
+
#
|
|
42
|
+
# == Never-crash boundary (NFR9, architecture verbatim)
|
|
43
|
+
#
|
|
44
|
+
# Every public method wraps its body in +rescue StandardError+ → an +error+ log
|
|
45
|
+
# line (format +Context#method: ...+) + the method's per-contract return value
|
|
46
|
+
# (+nil+ for lookups, +self+ for the chainable mutator). A raising collaborator
|
|
47
|
+
# degrades the call; it never crashes the host request.
|
|
48
|
+
class Context
|
|
49
|
+
# @param visitor_id [String] the resolved visitor id (validated non-blank by
|
|
50
|
+
# {Client#create_context} before construction).
|
|
51
|
+
# @param attributes [Hash, nil] the per-visitor attributes; deep-stringified
|
|
52
|
+
# here at the public boundary (nil → +{}+).
|
|
53
|
+
# @param data_manager [DataManager] the config reader surface (backs
|
|
54
|
+
# {#get_config_entity} and supplies the account/project key halves).
|
|
55
|
+
# @param data_store_manager [DataStoreManager] the persistence port (atomic
|
|
56
|
+
# visitor-data merge + reads).
|
|
57
|
+
# @param event_manager [EventManager] lifecycle pub/sub (held for the
|
|
58
|
+
# decisioning methods that land in later stories).
|
|
59
|
+
# @param log_manager [LogManager] the redacting logging surface.
|
|
60
|
+
# @param config [Config] the validated configuration surface.
|
|
61
|
+
# @param experience_manager [ExperienceManager, nil] the variation-selection
|
|
62
|
+
# surface backing {#run_experience}/{#run_experiences} (Story 2.11). nil
|
|
63
|
+
# leaves the shell decisioning-less (the 2.8 lookup-only construction).
|
|
64
|
+
# @param feature_manager [FeatureManager, nil] the feature-resolution +
|
|
65
|
+
# typed-variable-casting surface backing {#run_feature}/{#run_features}
|
|
66
|
+
# (Story 3.1). nil leaves the feature methods miss-only (no decisioning).
|
|
67
|
+
# @param segments_manager [SegmentsManager, nil] the visitor-segmentation
|
|
68
|
+
# surface backing {#set_default_segments}/{#run_custom_segments} (Story 3.2).
|
|
69
|
+
# nil leaves the segmentation methods inert (no persistence).
|
|
70
|
+
# @param api_manager [ApiManager, nil] the outbound delivery surface (Story
|
|
71
|
+
# 4.1). When wired, a fresh bucketing decision enqueues a +bucketing+ event
|
|
72
|
+
# at the single {#fire_bucketing} seam; nil leaves the enqueue inert.
|
|
73
|
+
def initialize(visitor_id:, data_manager:, data_store_manager:, event_manager:,
|
|
74
|
+
log_manager:, config:, attributes: nil, experience_manager: nil,
|
|
75
|
+
feature_manager: nil, segments_manager: nil, api_manager: nil)
|
|
76
|
+
@visitor_id = visitor_id
|
|
77
|
+
@data_manager = data_manager
|
|
78
|
+
@data_store_manager = data_store_manager
|
|
79
|
+
@event_manager = event_manager
|
|
80
|
+
@log_manager = log_manager
|
|
81
|
+
@config = config
|
|
82
|
+
@experience_manager = experience_manager
|
|
83
|
+
@feature_manager = feature_manager
|
|
84
|
+
@segments_manager = segments_manager
|
|
85
|
+
@api_manager = api_manager
|
|
86
|
+
# Deep-stringify the caller's attributes ONCE at the boundary; internals
|
|
87
|
+
# only ever see string keys. nil → empty. The caller's hash is never mutated.
|
|
88
|
+
@attributes = deep_stringify(attributes || {})
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [String] the visitor id this context is bound to.
|
|
92
|
+
attr_reader :visitor_id
|
|
93
|
+
|
|
94
|
+
# @return [Hash{String=>Object}] the in-memory, string-keyed attributes (the
|
|
95
|
+
# merged view subsequent decision methods read).
|
|
96
|
+
attr_reader :attributes
|
|
97
|
+
|
|
98
|
+
# Merge per-visitor properties into BOTH the stored +StoreData+ (atomically,
|
|
99
|
+
# via {DataStoreManager#merge_visitor_data}) and the in-memory attributes, so
|
|
100
|
+
# a later decision on THIS context sees the merge immediately (in-memory) and
|
|
101
|
+
# a later context for the same visitor sees it through the store (stickiness).
|
|
102
|
+
#
|
|
103
|
+
# Properties are deep-stringified at this public boundary and merged under the
|
|
104
|
+
# +StoreData+ +"segments"+ sub-key (JS +updateVisitorProperties+ stores
|
|
105
|
+
# +{segments: props}+ — +context.ts:482+). The merge is atomic per visitor:
|
|
106
|
+
# the read-modify-write runs inside the store manager's merge mutex.
|
|
107
|
+
#
|
|
108
|
+
# @param properties [Hash] the properties to merge (symbol or string keys).
|
|
109
|
+
# @return [self]
|
|
110
|
+
def update_visitor_properties(properties)
|
|
111
|
+
normalised = deep_stringify(properties || {})
|
|
112
|
+
@data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
|
|
113
|
+
{ "segments" => normalised }
|
|
114
|
+
end
|
|
115
|
+
@attributes = @attributes.merge(normalised)
|
|
116
|
+
self
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
@log_manager.error("Context#update_visitor_properties: #{e.class}: #{e.message}")
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read this visitor's persisted +StoreData+ from the store.
|
|
123
|
+
#
|
|
124
|
+
# Returns the stored, string-keyed +StoreData+ verbatim when present; when the
|
|
125
|
+
# visitor has no stored entry, returns the empty +StoreData+ shape
|
|
126
|
+
# +{"bucketing"=>{}, "segments"=>{}, "goals"=>{}}+ (a Ruby-specific stable
|
|
127
|
+
# shape — JS returns a bare +{}+ — so callers always get the three known
|
|
128
|
+
# sub-maps to read).
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash{String=>Object}] the visitor's StoreData (or the empty shape).
|
|
131
|
+
def get_visitor_data
|
|
132
|
+
key = @data_store_manager.visitor_key(account_key, project_key, @visitor_id)
|
|
133
|
+
stored = @data_store_manager.get(key)
|
|
134
|
+
stored.is_a?(Hash) ? stored : empty_store_data
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
@log_manager.error("Context#get_visitor_data: #{e.class}: #{e.message}")
|
|
137
|
+
empty_store_data
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Look up a config entity by key and type from the installed config snapshot.
|
|
141
|
+
#
|
|
142
|
+
# +entity_type+ names the collection — +:experience+ / +:feature+ / +:goal+
|
|
143
|
+
# (accepted as a symbol or a string; the value is matched verbatim after
|
|
144
|
+
# +to_s+, so it must be one of those three lowercase names) — and dispatches
|
|
145
|
+
# to the matching {DataManager} by-key reader. A miss (unknown key OR
|
|
146
|
+
# unknown/unmatched type) returns
|
|
147
|
+
# +nil+ and emits a +debug+ line
|
|
148
|
+
# (+Context#get_config_entity: no {type} found for key={key}+) — never a
|
|
149
|
+
# raise. (JS +getConfigEntity+ — +context.ts:495+ — returns +undefined+
|
|
150
|
+
# silently on a miss; the debug log is a Ruby-specific observability
|
|
151
|
+
# enhancement.)
|
|
152
|
+
#
|
|
153
|
+
# @param key [String] the entity +key+ to look up.
|
|
154
|
+
# @param entity_type [String, Symbol] the collection: experience/feature/goal.
|
|
155
|
+
# @return [Hash, nil] the frozen entity hash, or nil on a miss.
|
|
156
|
+
def get_config_entity(key, entity_type)
|
|
157
|
+
type = entity_type.to_s
|
|
158
|
+
entity =
|
|
159
|
+
case type
|
|
160
|
+
when "experience" then @data_manager.experience_by_key(key)
|
|
161
|
+
when "feature" then @data_manager.feature_by_key(key)
|
|
162
|
+
when "goal" then @data_manager.goal_by_key(key)
|
|
163
|
+
end
|
|
164
|
+
return entity unless entity.nil?
|
|
165
|
+
|
|
166
|
+
@log_manager.debug("Context#get_config_entity: no #{type} found for key=#{key}")
|
|
167
|
+
nil
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
@log_manager.error("Context#get_config_entity: #{e.class}: #{e.message}")
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Decide a single experience for this visitor and return its variation.
|
|
174
|
+
#
|
|
175
|
+
# The optional per-call +attributes+ are deep-stringified and merged OVER the
|
|
176
|
+
# context's own attributes (per-call wins), then handed to the ordered
|
|
177
|
+
# decision flow ({ExperienceManager#select_variation} -> {DataManager}). On a
|
|
178
|
+
# hit a frozen {BucketedVariation} is returned and the {SystemEvents::BUCKETING}
|
|
179
|
+
# lifecycle event fires (payload +{visitor_id, experience_key, variation_key}+,
|
|
180
|
+
# deferred for late subscribers — JS context.ts:153-162). On a miss the
|
|
181
|
+
# matching {Sentinel} ({RuleError}/{BucketingError}) is returned and NO event
|
|
182
|
+
# fires. The integrator pattern works on both:
|
|
183
|
+
#
|
|
184
|
+
# case (v = context.run_experience("homepage-test")).key
|
|
185
|
+
# when nil then render_default # a sentinel miss (key is nil)
|
|
186
|
+
# else render_variation(v.key) # a real decision
|
|
187
|
+
# end
|
|
188
|
+
#
|
|
189
|
+
# Never raises into the host: an internal failure degrades to
|
|
190
|
+
# {RuleError::NO_DATA_FOUND} + an +error+ log (NFR9).
|
|
191
|
+
#
|
|
192
|
+
# == Tracking control (Story 4.5)
|
|
193
|
+
#
|
|
194
|
+
# +attributes[:enable_tracking]+ (or +"enable_tracking"+) is the per-call
|
|
195
|
+
# tracking switch (snake_case of the JS +BucketingAttributes.enableTracking+).
|
|
196
|
+
# When +false+ THIS call still decides and still persists sticky StoreData, but
|
|
197
|
+
# NO bucketing event is enqueued (a +debug+ line records the suppression). The
|
|
198
|
+
# global Config +tracking: false+ switch ALWAYS wins over a per-call +true+.
|
|
199
|
+
#
|
|
200
|
+
# @param key [String] the experience +key+.
|
|
201
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
202
|
+
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
203
|
+
# @return [BucketedVariation, Sentinel] a frozen variation or a sentinel miss.
|
|
204
|
+
def run_experience(key, attributes = nil)
|
|
205
|
+
manager = @experience_manager
|
|
206
|
+
return RuleError::NO_DATA_FOUND if manager.nil?
|
|
207
|
+
|
|
208
|
+
@data_manager.ensure_fresh_config!
|
|
209
|
+
variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes))
|
|
210
|
+
fire_bucketing(key, variation, track: tracking_enabled_for_call?(attributes)) unless variation.is_a?(Sentinel)
|
|
211
|
+
variation
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
@log_manager.error("Context#run_experience: #{e.class}: #{e.message}")
|
|
214
|
+
RuleError::NO_DATA_FOUND
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Decide ALL applicable (running) experiences for this visitor and return the
|
|
218
|
+
# list of bucketed variations (FR16). Misses are FILTERED OUT (JS parity —
|
|
219
|
+
# experience-manager.ts:159-168): the list contains ONLY frozen
|
|
220
|
+
# {BucketedVariation}s the visitor was actually bucketed into, never sentinels.
|
|
221
|
+
# The {SystemEvents::BUCKETING} event fires once per returned variation
|
|
222
|
+
# (JS context.ts:209-222).
|
|
223
|
+
#
|
|
224
|
+
# context.run_experiences.each { |v| activate(v.experience_key, v.key) }
|
|
225
|
+
#
|
|
226
|
+
# Never raises into the host: an internal failure degrades to +[]+ + an
|
|
227
|
+
# +error+ log (NFR9).
|
|
228
|
+
#
|
|
229
|
+
# +attributes[:enable_tracking] == false+ suppresses the per-variation bucketing
|
|
230
|
+
# enqueue for THIS call (decisioning + sticky writes unaffected); the global
|
|
231
|
+
# Config +tracking: false+ switch always wins (Story 4.5).
|
|
232
|
+
#
|
|
233
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
234
|
+
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
235
|
+
# @return [Array<BucketedVariation>] the frozen variations (misses excluded).
|
|
236
|
+
def run_experiences(attributes = nil)
|
|
237
|
+
manager = @experience_manager
|
|
238
|
+
return [] if manager.nil?
|
|
239
|
+
|
|
240
|
+
@data_manager.ensure_fresh_config!
|
|
241
|
+
variations = manager.select_variations(@visitor_id, decision_attributes(attributes))
|
|
242
|
+
track = tracking_enabled_for_call?(attributes)
|
|
243
|
+
variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) }
|
|
244
|
+
variations
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
@log_manager.error("Context#run_experiences: #{e.class}: #{e.message}")
|
|
247
|
+
[]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Evaluate a SINGLE feature flag for this visitor with typed variables (FR24).
|
|
251
|
+
#
|
|
252
|
+
# The feature resolves THROUGH experience bucketing (FR26): it is ENABLED
|
|
253
|
+
# exactly when the visitor is bucketed (via the Story 2.11 decision flow) into
|
|
254
|
+
# a variation carrying that feature, and its variables arrive cast to their
|
|
255
|
+
# declared types (FR27 — see {FeatureManager#cast_type}). On a hit a frozen
|
|
256
|
+
# {BucketedFeature} (+status: enabled+) is returned; when the same feature is
|
|
257
|
+
# carried by SEVERAL bucketed variations an Array of enabled {BucketedFeature}s
|
|
258
|
+
# is returned (JS +runFeature+ parity). On a miss — feature undeclared, or the
|
|
259
|
+
# visitor bucketed into no carrying variation — a frozen DISABLED
|
|
260
|
+
# {BucketedFeature} is returned, never an exception (AC#5).
|
|
261
|
+
#
|
|
262
|
+
# Branch on +#status+ (never an error sentinel):
|
|
263
|
+
#
|
|
264
|
+
# feature = context.run_feature("new-checkout")
|
|
265
|
+
# if feature.status == ConvertSdk::FeatureStatus::ENABLED
|
|
266
|
+
# render_new_checkout(feature.variables["headline"])
|
|
267
|
+
# else
|
|
268
|
+
# render_legacy_checkout
|
|
269
|
+
# end
|
|
270
|
+
#
|
|
271
|
+
# NOTE (accepted parity break): JS +runFeature+ accepts an optional
|
|
272
|
+
# +experienceKeys+ filter argument; this Ruby surface intentionally OMITS it
|
|
273
|
+
# (deferred feature). Resolution always spans all configured experiences.
|
|
274
|
+
#
|
|
275
|
+
# Never raises into the host: an internal failure degrades to a DISABLED
|
|
276
|
+
# {BucketedFeature} (carrying the requested key) + an +error+ log (NFR9).
|
|
277
|
+
#
|
|
278
|
+
# @param key [String] the feature +key+ to evaluate.
|
|
279
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
280
|
+
# over the context attributes (deep-stringified).
|
|
281
|
+
# @return [BucketedFeature, Array<BucketedFeature>] the resolved feature(s).
|
|
282
|
+
def run_feature(key, attributes = nil)
|
|
283
|
+
manager = @feature_manager
|
|
284
|
+
return disabled_feature(key) if manager.nil?
|
|
285
|
+
|
|
286
|
+
@data_manager.ensure_fresh_config!
|
|
287
|
+
manager.run_feature(@visitor_id, key, decision_attributes(attributes))
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
@log_manager.error("Context#run_feature: #{e.class}: #{e.message}")
|
|
290
|
+
disabled_feature(key)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Evaluate ALL declared feature flags for this visitor with typed variables
|
|
294
|
+
# (FR25). Returns the full feature roster: every feature carried by a variation
|
|
295
|
+
# the visitor was bucketed into is ENABLED (variables cast to declared types);
|
|
296
|
+
# every other declared feature is DISABLED (JS +runFeatures+ parity, no feature
|
|
297
|
+
# filter). Misses never surface as exceptions or error sentinels.
|
|
298
|
+
#
|
|
299
|
+
# context.run_features.each do |feature|
|
|
300
|
+
# toggle(feature.key, on: feature.status == ConvertSdk::FeatureStatus::ENABLED)
|
|
301
|
+
# end
|
|
302
|
+
#
|
|
303
|
+
# Never raises into the host: an internal failure degrades to +[]+ + an
|
|
304
|
+
# +error+ log (NFR9).
|
|
305
|
+
#
|
|
306
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
307
|
+
# over the context attributes (deep-stringified).
|
|
308
|
+
# @return [Array<BucketedFeature>] the resolved features (enabled + disabled).
|
|
309
|
+
def run_features(attributes = nil)
|
|
310
|
+
manager = @feature_manager
|
|
311
|
+
return [] if manager.nil?
|
|
312
|
+
|
|
313
|
+
@data_manager.ensure_fresh_config!
|
|
314
|
+
manager.run_features(@visitor_id, decision_attributes(attributes))
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
@log_manager.error("Context#run_features: #{e.class}: #{e.message}")
|
|
317
|
+
[]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Set default report-segments for this visitor (FR28; JS +setDefaultSegments+
|
|
321
|
+
# -> +SegmentsManager#put_segments+, +context.ts:434-436+). The supplied
|
|
322
|
+
# segments are deep-stringified at this public boundary, then filtered to the
|
|
323
|
+
# seven JS {SegmentsManager::SEGMENTS_KEYS} report keys and merged into the
|
|
324
|
+
# visitor's +StoreData["segments"]+ (non-report keys are dropped). Caller
|
|
325
|
+
# supplies the JS wire keys (+visitorType+, +customSegments+, …) — these ARE
|
|
326
|
+
# the public contract (FR30); the diverged PHP variants are never produced.
|
|
327
|
+
#
|
|
328
|
+
# NO lifecycle event fires on segment attachment (JS parity — neither
|
|
329
|
+
# +setDefaultSegments+ nor +runCustomSegments+ fire +SystemEvents.SEGMENTS+).
|
|
330
|
+
#
|
|
331
|
+
# Never raises into the host: a failure degrades to an +error+ log and returns
|
|
332
|
+
# +self+ (NFR9).
|
|
333
|
+
#
|
|
334
|
+
# @param segments [Hash] the candidate report-segments (symbol or string keys).
|
|
335
|
+
# @return [self]
|
|
336
|
+
def set_default_segments(segments)
|
|
337
|
+
manager = @segments_manager
|
|
338
|
+
return self if manager.nil?
|
|
339
|
+
|
|
340
|
+
manager.put_segments(@visitor_id, deep_stringify(segments || {}))
|
|
341
|
+
self
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
@log_manager.error("Context#set_default_segments: #{e.class}: #{e.message}")
|
|
344
|
+
self
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Evaluate the named custom segments for this visitor and attach the matching
|
|
348
|
+
# segment ids (FR29; JS +runCustomSegments+, +context.ts:455-475+). For each
|
|
349
|
+
# key the {SegmentsManager} looks up the segment entity and evaluates its rules
|
|
350
|
+
# — via the Epic 2 {RuleManager} — against the visitor's properties (the
|
|
351
|
+
# context attributes deep-merged with the stored segments and the per-call
|
|
352
|
+
# +ruleData+, mirroring JS +getVisitorProperties+). Matching ids attach under
|
|
353
|
+
# +customSegments+ in +StoreData+. A surfaced {RuleError} sentinel is returned
|
|
354
|
+
# verbatim; otherwise +nil+ (JS returns the +RuleError+ union or +undefined+).
|
|
355
|
+
#
|
|
356
|
+
# NO lifecycle event fires on attachment (JS parity, F-014).
|
|
357
|
+
#
|
|
358
|
+
# Never raises into the host: a failure degrades to an +error+ log + +nil+ (NFR9).
|
|
359
|
+
#
|
|
360
|
+
# @param segment_keys [Array<String>] the segment keys to evaluate.
|
|
361
|
+
# @param attributes [Hash, nil] optional +{ruleData: {...}}+ visitor data the
|
|
362
|
+
# segment rules match against (deep-stringified, merged over the context
|
|
363
|
+
# attributes); +nil+ uses the context attributes alone.
|
|
364
|
+
# @return [Sentinel, nil] a propagated {RuleError}, or nil.
|
|
365
|
+
def run_custom_segments(segment_keys, attributes = nil)
|
|
366
|
+
manager = @segments_manager
|
|
367
|
+
return nil if manager.nil?
|
|
368
|
+
|
|
369
|
+
result = manager.select_custom_segments(@visitor_id, segment_keys, visitor_properties(attributes))
|
|
370
|
+
result.is_a?(Sentinel) ? result : nil
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
@log_manager.error("Context#run_custom_segments: #{e.class}: #{e.message}")
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Track a conversion for this visitor on +goal_key+ with optional revenue /
|
|
377
|
+
# transaction data, deduplicated per visitor per goal (FR31-FR35).
|
|
378
|
+
#
|
|
379
|
+
# The dedup decision + atomic mark live in {DataManager#convert} (the store
|
|
380
|
+
# merge lock makes check-then-mark one atomic op — the Android qs-01 fix);
|
|
381
|
+
# this surface wraps the returned wire-shaped +data+ hash into the
|
|
382
|
+
# +{eventType:'conversion', data:{...}}+ envelope (co-located with the
|
|
383
|
+
# bucketing-event construction site for consistency), enqueues it through the
|
|
384
|
+
# {ApiManager} (per-visitor merge, non-blocking — NFR2), and fires the
|
|
385
|
+
# {SystemEvents::CONVERSION} lifecycle event with +deferred: true+ so a
|
|
386
|
+
# listener that subscribes AFTER the call still receives the replay (JS
|
|
387
|
+
# context.ts:416-424). When the conversion is deduplicated or the goal key is
|
|
388
|
+
# unknown, {DataManager#convert} returns +nil+: no event is enqueued and
|
|
389
|
+
# CONVERSION does NOT fire.
|
|
390
|
+
#
|
|
391
|
+
# context.track_conversion("purchase", goal_data: { amount: 49.99, transaction_id: "tx-1" })
|
|
392
|
+
#
|
|
393
|
+
# +force_multiple_transactions: true+ bypasses the dedup check (a legitimate
|
|
394
|
+
# repeat transaction is enqueued) without re-marking the goal — see
|
|
395
|
+
# {DataManager#convert}.
|
|
396
|
+
#
|
|
397
|
+
# +goal_data+ accepts the eight {GoalDataKey} platform keys in snake_case
|
|
398
|
+
# symbol form (+amount:+, +products_count:+, +transaction_id:+,
|
|
399
|
+
# +custom_dimension_1:+ … +custom_dimension_5:+); unknown keys are rejected
|
|
400
|
+
# (debug-logged) and emitted as +[{key, value}]+ wire pairs.
|
|
401
|
+
#
|
|
402
|
+
# Never raises into the host: an internal failure degrades to an +error+ log
|
|
403
|
+
# and returns +self+ (NFR9).
|
|
404
|
+
#
|
|
405
|
+
# @param goal_key [String] the goal +key+ to convert on.
|
|
406
|
+
# @param goal_data [Hash, nil] optional revenue/transaction data (snake_case
|
|
407
|
+
# symbol keys of the eight platform keys).
|
|
408
|
+
# @param force_multiple_transactions [Boolean] bypass the per-goal dedup check.
|
|
409
|
+
# @return [self]
|
|
410
|
+
def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false)
|
|
411
|
+
# Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a
|
|
412
|
+
# suppressed conversion neither enqueues NOR marks dedup (the goals[goalId]
|
|
413
|
+
# mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal
|
|
414
|
+
# call therefore stays unblocked until tracking is re-enabled. Return value
|
|
415
|
+
# is unchanged (self); no sentinel.
|
|
416
|
+
unless @config.tracking
|
|
417
|
+
@log_manager.debug("Context#track_conversion: tracking disabled, event suppressed")
|
|
418
|
+
return self
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
@data_manager.ensure_fresh_config!
|
|
422
|
+
data = @data_manager.convert(
|
|
423
|
+
@visitor_id, goal_key,
|
|
424
|
+
goal_data: goal_data,
|
|
425
|
+
force_multiple_transactions: force_multiple_transactions
|
|
426
|
+
)
|
|
427
|
+
fire_conversion(goal_key, data) unless data.nil?
|
|
428
|
+
self
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
@log_manager.error("Context#track_conversion: #{e.class}: #{e.message}")
|
|
431
|
+
self
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
# The single conversion seam (mirrors {#fire_bucketing}): enqueue the
|
|
437
|
+
# wire-shaped event THEN fire the lifecycle event with +deferred: true+ (late
|
|
438
|
+
# subscribers replay — JS context.ts:416-424). Fired on SUCCESS only (the
|
|
439
|
+
# caller skips this when {DataManager#convert} returned nil).
|
|
440
|
+
def fire_conversion(goal_key, data)
|
|
441
|
+
enqueue_conversion_event(data)
|
|
442
|
+
@event_manager.fire(
|
|
443
|
+
SystemEvents::CONVERSION,
|
|
444
|
+
{ visitor_id: @visitor_id, goal_key: goal_key },
|
|
445
|
+
nil,
|
|
446
|
+
deferred: true
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Wrap the {DataManager#convert} wire-shaped +data+ hash into the conversion
|
|
451
|
+
# event envelope and enqueue it (no-op when no {ApiManager} is wired).
|
|
452
|
+
# Co-located with {#enqueue_bucketing_event}: both wire-shape at construction
|
|
453
|
+
# time; the ApiManager remains the only payload BUILDER. The visitor's stored
|
|
454
|
+
# report-segments ride the queue's first entry (passed nil when empty so the
|
|
455
|
+
# wire entry omits +segments+) — same convention as the bucketing event.
|
|
456
|
+
def enqueue_conversion_event(data)
|
|
457
|
+
manager = @api_manager
|
|
458
|
+
return if manager.nil?
|
|
459
|
+
|
|
460
|
+
event = { "eventType" => SystemEvents::CONVERSION, "data" => data }
|
|
461
|
+
stored_segments = get_visitor_data["segments"]
|
|
462
|
+
segments = stored_segments.is_a?(Hash) && !stored_segments.empty? ? stored_segments : nil
|
|
463
|
+
manager.enqueue(@visitor_id, event, segments: segments)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Build the visitor properties the segment rules match against — JS
|
|
467
|
+
# +getVisitorProperties+ (+context.ts:569-577+): the stored segments deep-merged
|
|
468
|
+
# UNDER the context attributes deep-merged with the per-call +ruleData+. The
|
|
469
|
+
# per-call +ruleData+ (and context attributes) win over stored segments. All
|
|
470
|
+
# deep-stringified to string keys (the rule engine reads string keys).
|
|
471
|
+
def visitor_properties(attributes)
|
|
472
|
+
rule_data = attributes.is_a?(Hash) ? (attributes[:ruleData] || attributes["ruleData"]) : nil
|
|
473
|
+
empty = {} #: Hash[String, untyped]
|
|
474
|
+
merged = @attributes.merge(deep_stringify(rule_data || empty))
|
|
475
|
+
stored = get_visitor_data["segments"]
|
|
476
|
+
stored = empty unless stored.is_a?(Hash)
|
|
477
|
+
stored.merge(merged)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# A frozen DISABLED {BucketedFeature} carrying the requested key — the miss /
|
|
481
|
+
# internal-failure return for {#run_feature} (never an exception, AC#5).
|
|
482
|
+
def disabled_feature(key)
|
|
483
|
+
BucketedFeature.new(key: key, status: FeatureStatus::DISABLED)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Build the bucketing-attributes hash for the decision flow: the context
|
|
487
|
+
# attributes deep-merged with the deep-stringified per-call attributes
|
|
488
|
+
# (per-call wins). The merged map is the +visitor_properties+ that drive the
|
|
489
|
+
# AUDIENCE step. +location_properties+ are a SEPARATE optional attribute (JS
|
|
490
|
+
# context.ts:135-143 spreads only an explicit +attributes.locationProperties+;
|
|
491
|
+
# it never defaults location matching to the visitor properties) — supplied
|
|
492
|
+
# only when the caller passes +location_properties+/+"location_properties"+.
|
|
493
|
+
# +environment+ is lifted out so the flow's environment-match step sees it.
|
|
494
|
+
def decision_attributes(per_call)
|
|
495
|
+
merged = @attributes.merge(deep_stringify(per_call || {}))
|
|
496
|
+
{
|
|
497
|
+
visitor_properties: merged,
|
|
498
|
+
location_properties: merged["location_properties"],
|
|
499
|
+
environment: merged["environment"]
|
|
500
|
+
}
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# The single named seam fired once per fresh/decided variation. It does TWO
|
|
504
|
+
# things at this one site (Story 2.11 fired the lifecycle event; Story 4.1
|
|
505
|
+
# completes the deferred enqueue here — never a SECOND fire):
|
|
506
|
+
#
|
|
507
|
+
# 1. Fires the {SystemEvents::BUCKETING} lifecycle event (deferred so late
|
|
508
|
+
# subscribers are replayed).
|
|
509
|
+
# 2. Enqueues the wire-shaped +bucketing+ event into the {ApiManager} queue
|
|
510
|
+
# (when one is wired) so {Client#flush} delivers it — string-keyed camelCase
|
|
511
|
+
# +{eventType:'bucketing', data:{experienceId, variationId}}+. The visitor's
|
|
512
|
+
# stored report-segments ride on the queue's first entry (JS parity —
|
|
513
|
+
# data-manager.ts:692-694); an empty segments map is passed as +nil+ so the
|
|
514
|
+
# wire entry omits the +segments+ key entirely.
|
|
515
|
+
#
|
|
516
|
+
# The SOLE bucketing enqueue site. The {SystemEvents::BUCKETING} LIFECYCLE event
|
|
517
|
+
# ALWAYS fires (it is decisioning observability, not tracking — a host listener
|
|
518
|
+
# may need to react to the decision even under consent denial); only the
|
|
519
|
+
# outbound ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
|
|
520
|
+
# composed verdict ({#tracking_enabled_for_call?} — global AND per-call); when +false+
|
|
521
|
+
# the wire enqueue is suppressed with a +debug+ line and stickiness/decisioning
|
|
522
|
+
# are untouched. Contained — a raising listener never crosses back (EventManager
|
|
523
|
+
# swallows it); the enqueue is pure in-memory and inert when no ApiManager is wired.
|
|
524
|
+
def fire_bucketing(experience_key, variation, track: true)
|
|
525
|
+
@event_manager.fire(
|
|
526
|
+
SystemEvents::BUCKETING,
|
|
527
|
+
{ visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
|
|
528
|
+
nil,
|
|
529
|
+
deferred: true
|
|
530
|
+
)
|
|
531
|
+
return if suppress_bucketing_enqueue?(track)
|
|
532
|
+
|
|
533
|
+
enqueue_bucketing_event(variation)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# The composed tracking verdict for THIS bucketing enqueue: suppressed when the
|
|
537
|
+
# global Config switch is off OR the per-call +track+ flag is false. Emits the
|
|
538
|
+
# matching +debug+ suppression line (global vs per-call) so every suppressed
|
|
539
|
+
# enqueue is observable (FR56). Returns true when the enqueue must be skipped.
|
|
540
|
+
def suppress_bucketing_enqueue?(track)
|
|
541
|
+
unless @config.tracking
|
|
542
|
+
@log_manager.debug("Context#run_experience: tracking disabled, event suppressed")
|
|
543
|
+
return true
|
|
544
|
+
end
|
|
545
|
+
unless track
|
|
546
|
+
@log_manager.debug("Context#run_experience: tracking suppressed for call")
|
|
547
|
+
return true
|
|
548
|
+
end
|
|
549
|
+
false
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Read the per-call +enable_tracking+ switch from the per-call attributes
|
|
553
|
+
# (symbol or string key; the public boundary accepts both — FR11). Absent =>
|
|
554
|
+
# +true+ (tracking on by default). Only +false+ (an explicit per-call opt-out)
|
|
555
|
+
# suppresses; any other value leaves tracking on. The global Config switch is
|
|
556
|
+
# composed separately in {#suppress_bucketing_enqueue?} (global-off always wins).
|
|
557
|
+
def tracking_enabled_for_call?(attributes)
|
|
558
|
+
return true unless attributes.is_a?(Hash)
|
|
559
|
+
|
|
560
|
+
value = attributes.fetch(:enable_tracking) { attributes.fetch("enable_tracking", true) }
|
|
561
|
+
value != false
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Enqueue the wire-shaped bucketing event for the decided variation (no-op when
|
|
565
|
+
# no {ApiManager} is wired). The event keys are camelCase strings sourced from
|
|
566
|
+
# the {BucketedVariation} value object; segments ride from the visitor's
|
|
567
|
+
# StoreData (passed as nil when empty so the wire entry omits them).
|
|
568
|
+
def enqueue_bucketing_event(variation)
|
|
569
|
+
manager = @api_manager
|
|
570
|
+
return if manager.nil?
|
|
571
|
+
|
|
572
|
+
event = {
|
|
573
|
+
"eventType" => SystemEvents::BUCKETING,
|
|
574
|
+
"data" => {
|
|
575
|
+
"experienceId" => variation.experience_id,
|
|
576
|
+
"variationId" => variation.id
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
stored_segments = get_visitor_data["segments"]
|
|
580
|
+
segments = stored_segments.is_a?(Hash) && !stored_segments.empty? ? stored_segments : nil
|
|
581
|
+
manager.enqueue(@visitor_id, event, segments: segments)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# The account half of the visitor store key. The {DataManager} reader is
|
|
585
|
+
# +nil+ before any config is installed (degrade-gracefully, NFR12); coerced
|
|
586
|
+
# to +""+ here so the key builder (which interpolates) gets a String. A
|
|
587
|
+
# pre-config key is degenerate but harmless — there is no config to decide on.
|
|
588
|
+
def account_key
|
|
589
|
+
@data_manager.account_id.to_s
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# The project half of the visitor store key (see {#account_key}).
|
|
593
|
+
def project_key
|
|
594
|
+
@data_manager.project_id.to_s
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# The empty +StoreData+ shape returned when a visitor has no persisted data.
|
|
598
|
+
def empty_store_data
|
|
599
|
+
{ "bucketing" => {}, "segments" => {}, "goals" => {} }
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Recursively normalise a (possibly symbol-keyed) hash/array graph to string
|
|
603
|
+
# keys — the public-boundary normalisation (FR11). Only KEYS are stringified;
|
|
604
|
+
# values pass through unchanged. The caller's original graph is never mutated.
|
|
605
|
+
def deep_stringify(node)
|
|
606
|
+
case node
|
|
607
|
+
when Hash
|
|
608
|
+
result = {} #: Hash[String, untyped]
|
|
609
|
+
node.each { |k, v| result[k.to_s] = deep_stringify(v) }
|
|
610
|
+
result
|
|
611
|
+
when Array
|
|
612
|
+
node.map { |element| deep_stringify(element) }
|
|
613
|
+
else
|
|
614
|
+
node
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|