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,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