convert_sdk 1.0.1 → 2.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 +4 -4
- data/.rubocop.yml +9 -0
- data/CONTRIBUTING.md +1 -1
- data/lib/convert_sdk/api_manager.rb +167 -0
- data/lib/convert_sdk/bucketing_manager.rb +161 -0
- data/lib/convert_sdk/client.rb +42 -7
- data/lib/convert_sdk/config.rb +20 -2
- data/lib/convert_sdk/config_validator.rb +18 -2
- data/lib/convert_sdk/context.rb +267 -15
- data/lib/convert_sdk/data_manager.rb +158 -15
- data/lib/convert_sdk/redactor.rb +6 -4
- data/lib/convert_sdk/rule_manager.rb +70 -11
- data/lib/convert_sdk/segments_manager.rb +34 -10
- data/lib/convert_sdk/version.rb +1 -1
- data/lib/convert_sdk.rb +42 -0
- metadata +1 -1
data/lib/convert_sdk/context.rb
CHANGED
|
@@ -86,6 +86,11 @@ module ConvertSdk
|
|
|
86
86
|
# Deep-stringify the caller's attributes ONCE at the boundary; internals
|
|
87
87
|
# only ever see string keys. nil → empty. The caller's hash is never mutated.
|
|
88
88
|
@attributes = deep_stringify(attributes || {})
|
|
89
|
+
# qs-03 (RB-5) preview state — nil until {#set_preview} succeeds. A plain
|
|
90
|
+
# per-instance ivar (never a class/shared variable): two Contexts NEVER
|
|
91
|
+
# share this (AC7 isolation). Shape: +{experience_id:, variation_id:,
|
|
92
|
+
# experience:, experience_key:}+.
|
|
93
|
+
@preview = nil #: Hash[Symbol, untyped]?
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
# @return [String] the visitor id this context is bound to.
|
|
@@ -105,12 +110,25 @@ module ConvertSdk
|
|
|
105
110
|
# +{segments: props}+ — +context.ts:482+). The merge is atomic per visitor:
|
|
106
111
|
# the read-modify-write runs inside the store manager's merge mutex.
|
|
107
112
|
#
|
|
113
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
114
|
+
#
|
|
115
|
+
# On a preview-active context ONLY the store write below is skipped — the
|
|
116
|
+
# in-memory +@attributes+ merge always applies (JS parity: JS's public
|
|
117
|
+
# +updateVisitorProperties+ has no preview guard of its own, but the
|
|
118
|
+
# PRIVATE helper it calls to persist DOES skip entirely under preview —
|
|
119
|
+
# +context.ts:626-629+, +if (this._preview) return;+ — leaving the
|
|
120
|
+
# in-memory side unaffected there too). "Per-context scratch" per the
|
|
121
|
+
# qs-03 spec: a later decision on THIS context still sees the merge; no
|
|
122
|
+
# trace of it ever reaches the store.
|
|
123
|
+
#
|
|
108
124
|
# @param properties [Hash] the properties to merge (symbol or string keys).
|
|
109
125
|
# @return [self]
|
|
110
126
|
def update_visitor_properties(properties)
|
|
111
127
|
normalised = deep_stringify(properties || {})
|
|
112
|
-
@
|
|
113
|
-
|
|
128
|
+
if @preview.nil?
|
|
129
|
+
@data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
|
|
130
|
+
{ "segments" => normalised }
|
|
131
|
+
end
|
|
114
132
|
end
|
|
115
133
|
@attributes = @attributes.merge(normalised)
|
|
116
134
|
self
|
|
@@ -170,6 +188,79 @@ module ConvertSdk
|
|
|
170
188
|
nil
|
|
171
189
|
end
|
|
172
190
|
|
|
191
|
+
# Force a specific variation of an experience for THIS context — bypassing
|
|
192
|
+
# audiences, segments, locations, the environment check, experience status,
|
|
193
|
+
# variation status/traffic filters, stored decisions, and the bucketing
|
|
194
|
+
# hash for that experience only (qs-03 AC4/AC5). Mirrors JS
|
|
195
|
+
# +Context#setPreview+ (+context.ts:143-205+); this Ruby surface is
|
|
196
|
+
# synchronous — the +?exp=+ fallback fetch runs through
|
|
197
|
+
# {ApiManager#get_config_by_experience}, itself process-wide memoized for
|
|
198
|
+
# 60s (qs-03 AC8), so no async/await equivalent is needed here.
|
|
199
|
+
#
|
|
200
|
+
# == Resolution
|
|
201
|
+
#
|
|
202
|
+
# The CURRENT installed config is tried first, by id
|
|
203
|
+
# ({DataManager#experience_by_id}); when absent, a live
|
|
204
|
+
# +?exp={experience_id}+ fetch resolves it instead (never touches the
|
|
205
|
+
# installed config or the store — a previewed experience may be draft/
|
|
206
|
+
# paused and must never be cached alongside production config). The
|
|
207
|
+
# resolved experience is held BY REFERENCE, never duped or rebuilt:
|
|
208
|
+
# DataManager's installed config entities are already deep-frozen (Story
|
|
209
|
+
# 2.7), so simply holding the reference carries none of the JS SDK-7
|
|
210
|
+
# in-place-mutation risk (mutating a frozen Ruby Hash raises
|
|
211
|
+
# +FrozenError+ rather than silently corrupting shared state); a fetched
|
|
212
|
+
# experience is a brand-new object that is never installed anywhere, so it
|
|
213
|
+
# is never shared to begin with.
|
|
214
|
+
#
|
|
215
|
+
# == Inert on bad input (AC7)
|
|
216
|
+
#
|
|
217
|
+
# A blank +experience_id+/+variation_id+, an unresolvable experience
|
|
218
|
+
# (absent from both the installed config and the +?exp=+ fetch response),
|
|
219
|
+
# or an unknown +variation_id+ on the resolved experience all leave preview
|
|
220
|
+
# state UNSET (a +warn+ log, never a raise) — a subsequent
|
|
221
|
+
# {#run_experience} on this context decides exactly as if this method had
|
|
222
|
+
# never been called.
|
|
223
|
+
#
|
|
224
|
+
# == Isolation (AC7)
|
|
225
|
+
#
|
|
226
|
+
# Preview state lives on THIS +Context+ instance only (a plain ivar) — two
|
|
227
|
+
# +Context+s, even for the same client/config, never share it.
|
|
228
|
+
#
|
|
229
|
+
# Never raises into the host: an internal failure degrades to an +error+
|
|
230
|
+
# log + leaves preview state unset (NFR9).
|
|
231
|
+
#
|
|
232
|
+
# @param experience_id [String] the previewed experience's +id+.
|
|
233
|
+
# @param variation_id [String] the variation +id+ to force.
|
|
234
|
+
# @return [self]
|
|
235
|
+
def set_preview(experience_id:, variation_id:)
|
|
236
|
+
return warn_preview_inert("experience_id/variation_id required") if blank?(experience_id) || blank?(variation_id)
|
|
237
|
+
|
|
238
|
+
# Coerce ONCE at this public entry (review round 2) — ids may arrive as
|
|
239
|
+
# different types (an Integer id read straight off a link param, a
|
|
240
|
+
# String elsewhere); every downstream reference (resolution, the
|
|
241
|
+
# decision lookup, the stored @preview hash) threads these coerced
|
|
242
|
+
# locals instead of the original keyword args, mirroring
|
|
243
|
+
# {#fetch_preview_experience}'s existing "ids may arrive as different
|
|
244
|
+
# types" +.to_s+ convention.
|
|
245
|
+
exp_id = experience_id.to_s
|
|
246
|
+
var_id = variation_id.to_s
|
|
247
|
+
|
|
248
|
+
experience = resolve_preview_experience(exp_id)
|
|
249
|
+
return warn_preview_inert("no experience found for id=#{exp_id}", clear: true) if experience.nil?
|
|
250
|
+
|
|
251
|
+
decision = @data_manager.get_preview_decision(experience, var_id)
|
|
252
|
+
return warn_preview_inert("no variation found for id=#{var_id}", clear: true) if decision.nil?
|
|
253
|
+
|
|
254
|
+
@preview = {
|
|
255
|
+
experience_id: exp_id, variation_id: var_id,
|
|
256
|
+
experience: experience, experience_key: experience["key"]
|
|
257
|
+
}
|
|
258
|
+
self
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
@log_manager.error("Context#set_preview: #{e.class}: #{e.message}")
|
|
261
|
+
self
|
|
262
|
+
end
|
|
263
|
+
|
|
173
264
|
# Decide a single experience for this visitor and return its variation.
|
|
174
265
|
#
|
|
175
266
|
# The optional per-call +attributes+ are deep-stringified and merged OVER the
|
|
@@ -197,6 +288,29 @@ module ConvertSdk
|
|
|
197
288
|
# NO bucketing event is enqueued (a +debug+ line records the suppression). The
|
|
198
289
|
# global Config +tracking: false+ switch ALWAYS wins over a per-call +true+.
|
|
199
290
|
#
|
|
291
|
+
# == Preview forcing (qs-03 AC4/AC5)
|
|
292
|
+
#
|
|
293
|
+
# When {#set_preview} has forced a variation for THIS +key+ on this context,
|
|
294
|
+
# that forced decision is returned DIRECTLY — bypassing decisioning entirely
|
|
295
|
+
# (no audience/location/environment/status/traffic/stored-decision/bucketing
|
|
296
|
+
# walk) and firing NO {SystemEvents::BUCKETING} event (mirrors JS
|
|
297
|
+
# +context.ts:228-235+). Because this check runs FIRST, it naturally takes
|
|
298
|
+
# precedence over a stored decision or fresh bucketing for that experience;
|
|
299
|
+
# every OTHER experience on this same context still decides normally.
|
|
300
|
+
#
|
|
301
|
+
# == Zero-trace on OTHER experiences under preview (qs-03 AC6, RB-6)
|
|
302
|
+
#
|
|
303
|
+
# When THIS context has a preview active but +key+ is NOT the previewed
|
|
304
|
+
# experience, decisioning proceeds NORMALLY — only tracking/persistence are
|
|
305
|
+
# suppressed: {#decision_attributes} threads +enable_storage: false+ so
|
|
306
|
+
# {DataManager#persist_bucketing} never writes sticky StoreData, and the
|
|
307
|
+
# per-call tracking verdict is forced +false+ so {#fire_bucketing}'s
|
|
308
|
+
# {#suppress_bucketing_enqueue?} skips the outbound enqueue.
|
|
309
|
+
# {#fire_bucketing} ALSO suppresses the in-process {SystemEvents::BUCKETING}
|
|
310
|
+
# lifecycle-event fire while a preview is active on this context (JS parity
|
|
311
|
+
# — +context.ts:260+: +if (!this._preview) { fire BUCKETING }+). No event
|
|
312
|
+
# fires for any experience on a preview-active context.
|
|
313
|
+
#
|
|
200
314
|
# @param key [String] the experience +key+.
|
|
201
315
|
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
202
316
|
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
@@ -205,9 +319,13 @@ module ConvertSdk
|
|
|
205
319
|
manager = @experience_manager
|
|
206
320
|
return RuleError::NO_DATA_FOUND if manager.nil?
|
|
207
321
|
|
|
322
|
+
preview = @preview
|
|
323
|
+
return forced_preview_variation(preview) if preview && key == preview[:experience_key]
|
|
324
|
+
|
|
208
325
|
@data_manager.ensure_fresh_config!
|
|
209
326
|
variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes))
|
|
210
|
-
|
|
327
|
+
track = preview.nil? && tracking_enabled_for_call?(attributes)
|
|
328
|
+
fire_bucketing(key, variation, track: track) unless variation.is_a?(Sentinel)
|
|
211
329
|
variation
|
|
212
330
|
rescue StandardError => e
|
|
213
331
|
@log_manager.error("Context#run_experience: #{e.class}: #{e.message}")
|
|
@@ -230,6 +348,19 @@ module ConvertSdk
|
|
|
230
348
|
# enqueue for THIS call (decisioning + sticky writes unaffected); the global
|
|
231
349
|
# Config +tracking: false+ switch always wins (Story 4.5).
|
|
232
350
|
#
|
|
351
|
+
# On a preview-active context (qs-03 AC6, RB-6) the previewed experience is
|
|
352
|
+
# FORCED to its preview variation here too — run-all forcing is API-agnostic,
|
|
353
|
+
# matching {#run_experience} and the PHP/Android/Python/iOS SDKs. Its
|
|
354
|
+
# normally-decided entry is dropped and the forced variation appended (or
|
|
355
|
+
# appended outright when the previewed experience is absent from the run-all
|
|
356
|
+
# set, e.g. a draft resolved only via +?exp=+); the forced entry fires no
|
|
357
|
+
# {SystemEvents::BUCKETING} event, exactly as {#run_experience}'s forced
|
|
358
|
+
# branch returns before {#fire_bucketing}. Every OTHER decided variation is
|
|
359
|
+
# zero-trace like {#run_experience}'s other-experience branch:
|
|
360
|
+
# {#decision_attributes} suppresses the sticky persist and the tracking
|
|
361
|
+
# verdict is forced +false+, while the {SystemEvents::BUCKETING} event still
|
|
362
|
+
# fires per variation (see {#run_experience}'s doc for the Ruby/JS divergence).
|
|
363
|
+
#
|
|
233
364
|
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
234
365
|
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
235
366
|
# @return [Array<BucketedVariation>] the frozen variations (misses excluded).
|
|
@@ -239,6 +370,9 @@ module ConvertSdk
|
|
|
239
370
|
|
|
240
371
|
@data_manager.ensure_fresh_config!
|
|
241
372
|
variations = manager.select_variations(@visitor_id, decision_attributes(attributes))
|
|
373
|
+
preview = @preview
|
|
374
|
+
return force_preview_in_run_all(variations, preview) if preview
|
|
375
|
+
|
|
242
376
|
track = tracking_enabled_for_call?(attributes)
|
|
243
377
|
variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) }
|
|
244
378
|
variations
|
|
@@ -328,6 +462,12 @@ module ConvertSdk
|
|
|
328
462
|
# NO lifecycle event fires on segment attachment (JS parity — neither
|
|
329
463
|
# +setDefaultSegments+ nor +runCustomSegments+ fire +SystemEvents.SEGMENTS+).
|
|
330
464
|
#
|
|
465
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
466
|
+
#
|
|
467
|
+
# +enable_storage: @preview.nil?+ threads through to
|
|
468
|
+
# {SegmentsManager#put_segments}, suppressing ONLY the persistence write
|
|
469
|
+
# (mirrors JS SDK-6, +context.ts:571+ — +!this._preview+ passed the same way).
|
|
470
|
+
#
|
|
331
471
|
# Never raises into the host: a failure degrades to an +error+ log and returns
|
|
332
472
|
# +self+ (NFR9).
|
|
333
473
|
#
|
|
@@ -337,7 +477,7 @@ module ConvertSdk
|
|
|
337
477
|
manager = @segments_manager
|
|
338
478
|
return self if manager.nil?
|
|
339
479
|
|
|
340
|
-
manager.put_segments(@visitor_id, deep_stringify(segments || {}))
|
|
480
|
+
manager.put_segments(@visitor_id, deep_stringify(segments || {}), enable_storage: @preview.nil?)
|
|
341
481
|
self
|
|
342
482
|
rescue StandardError => e
|
|
343
483
|
@log_manager.error("Context#set_default_segments: #{e.class}: #{e.message}")
|
|
@@ -355,6 +495,13 @@ module ConvertSdk
|
|
|
355
495
|
#
|
|
356
496
|
# NO lifecycle event fires on attachment (JS parity, F-014).
|
|
357
497
|
#
|
|
498
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
499
|
+
#
|
|
500
|
+
# +enable_storage: @preview.nil?+ threads through to
|
|
501
|
+
# {SegmentsManager#select_custom_segments}, suppressing ONLY the persistence
|
|
502
|
+
# write — rule MATCHING still runs exactly as normal (mirrors JS SDK-6,
|
|
503
|
+
# +context.ts:610+ — +!this._preview+ passed the same way).
|
|
504
|
+
#
|
|
358
505
|
# Never raises into the host: a failure degrades to an +error+ log + +nil+ (NFR9).
|
|
359
506
|
#
|
|
360
507
|
# @param segment_keys [Array<String>] the segment keys to evaluate.
|
|
@@ -366,7 +513,9 @@ module ConvertSdk
|
|
|
366
513
|
manager = @segments_manager
|
|
367
514
|
return nil if manager.nil?
|
|
368
515
|
|
|
369
|
-
result = manager.select_custom_segments(
|
|
516
|
+
result = manager.select_custom_segments(
|
|
517
|
+
@visitor_id, segment_keys, visitor_properties(attributes), enable_storage: @preview.nil?
|
|
518
|
+
)
|
|
370
519
|
result.is_a?(Sentinel) ? result : nil
|
|
371
520
|
rescue StandardError => e
|
|
372
521
|
@log_manager.error("Context#run_custom_segments: #{e.class}: #{e.message}")
|
|
@@ -408,6 +557,14 @@ module ConvertSdk
|
|
|
408
557
|
# @param force_multiple_transactions [Boolean] bypass the per-goal dedup check.
|
|
409
558
|
# @return [self]
|
|
410
559
|
def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false)
|
|
560
|
+
# qs-03 (RB-6) — zero-trace: a preview-active context is a FULL no-op here,
|
|
561
|
+
# checked BEFORE the global tracking gate below (and BEFORE
|
|
562
|
+
# DataManager#convert) so NOTHING happens — no enqueue, no CONVERSION
|
|
563
|
+
# event, and no dedup mark (the mark lives inside #convert's atomic
|
|
564
|
+
# dedup-and-mark, never reached). Mirrors JS +context.ts:512-519+
|
|
565
|
+
# (+if (this._preview) return;+ at the top of +trackConversion+).
|
|
566
|
+
return self if @preview
|
|
567
|
+
|
|
411
568
|
# Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a
|
|
412
569
|
# suppressed conversion neither enqueues NOR marks dedup (the goals[goalId]
|
|
413
570
|
# mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal
|
|
@@ -433,6 +590,86 @@ module ConvertSdk
|
|
|
433
590
|
|
|
434
591
|
private
|
|
435
592
|
|
|
593
|
+
# {#set_preview}'s inert-path helper: warn-log +detail+ under the
|
|
594
|
+
# +Context#set_preview+ prefix (the single log-message shape every inert
|
|
595
|
+
# branch shares — AC7) and return +self+.
|
|
596
|
+
#
|
|
597
|
+
# +clear:+ mirrors JS +Context#setPreview+ (+context.ts:143-205+) exactly:
|
|
598
|
+
# the BLANK-input guard (this method's caller with +clear: false+, the
|
|
599
|
+
# default) leaves a prior successful +@preview+ untouched — JS's own
|
|
600
|
+
# +if (!experienceId || !variationId)+ branch returns without touching
|
|
601
|
+
# +this._preview+ (+context.ts:146-152+). Every OTHER failure path — an
|
|
602
|
+
# unresolvable experience (+context.ts:172,182+) or an unknown variation id
|
|
603
|
+
# on the resolved experience (+context.ts:195+) — explicitly nulls the
|
|
604
|
+
# preview (JS: +this._preview = null;+ before each of those returns), so a
|
|
605
|
+
# FAILED re-preview after a prior success falls back to normal decisioning
|
|
606
|
+
# rather than stranding the stale forced pick. Callers pass +clear: true+
|
|
607
|
+
# for those two paths only.
|
|
608
|
+
def warn_preview_inert(detail, clear: false)
|
|
609
|
+
@preview = nil if clear
|
|
610
|
+
@log_manager.warn("Context#set_preview: #{detail}")
|
|
611
|
+
self
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# {#run_experience}'s preview-forcing branch: force-decide THIS visitor's
|
|
615
|
+
# previewed variation via {DataManager#get_preview_decision}. +#set_preview+
|
|
616
|
+
# only ever stores a preview whose (experience, variation_id) pair already
|
|
617
|
+
# resolved a decision, and the resolved experience is a frozen Hash that can
|
|
618
|
+
# never drift afterward, so a nil result here is a defensive fallback, not
|
|
619
|
+
# an expected path.
|
|
620
|
+
def forced_preview_variation(preview)
|
|
621
|
+
@data_manager.get_preview_decision(preview[:experience], preview[:variation_id]) || RuleError::NO_DATA_FOUND
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# {#run_experiences}'s preview branch, extracted to keep #run_experiences
|
|
625
|
+
# within RuboCop's ABC/complexity budget: drop the previewed experience's
|
|
626
|
+
# normally-decided entry (so it fires no BUCKETING event for the overridden
|
|
627
|
+
# decision), fire every OTHER experience's event with tracking suppressed,
|
|
628
|
+
# then append the forced variation. A defensive Sentinel from
|
|
629
|
+
# {#forced_preview_variation} (set_preview pre-validates, so not expected)
|
|
630
|
+
# appends nothing.
|
|
631
|
+
def force_preview_in_run_all(variations, preview)
|
|
632
|
+
others = variations.reject { |variation| variation.experience_key == preview[:experience_key] }
|
|
633
|
+
others.each { |variation| fire_bucketing(variation.experience_key, variation, track: false) }
|
|
634
|
+
forced = forced_preview_variation(preview)
|
|
635
|
+
forced.is_a?(Sentinel) ? others : others + [forced]
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Resolve the previewed experience for {#set_preview}: the installed
|
|
639
|
+
# config's by-id reader first ({DataManager#experience_by_id}); when
|
|
640
|
+
# absent, the +?exp=+ live-fetch fallback ({#fetch_preview_experience}).
|
|
641
|
+
def resolve_preview_experience(experience_id)
|
|
642
|
+
@data_manager.experience_by_id(experience_id) || fetch_preview_experience(experience_id)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# The +?exp={experience_id}+ fallback fetch ({ApiManager#get_config_by_experience},
|
|
646
|
+
# process-wide memoized for 60s — qs-03 AC8) for an experience absent from
|
|
647
|
+
# the installed config (e.g. a draft/paused preview target). A nil
|
|
648
|
+
# {ApiManager} (no Client-wired collaborator) or a failed fetch (the port
|
|
649
|
+
# never raises — it degrades to nil) both resolve to a miss here; the
|
|
650
|
+
# response's own +experiences+ collection is scanned by id (+to_s+
|
|
651
|
+
# compared, ids may arrive as different types).
|
|
652
|
+
def fetch_preview_experience(experience_id)
|
|
653
|
+
manager = @api_manager
|
|
654
|
+
return nil if manager.nil?
|
|
655
|
+
|
|
656
|
+
config = manager.get_config_by_experience(experience_id)
|
|
657
|
+
return nil unless config.is_a?(Hash)
|
|
658
|
+
|
|
659
|
+
experiences = config["experiences"]
|
|
660
|
+
return nil unless experiences.is_a?(Array)
|
|
661
|
+
|
|
662
|
+
target = experience_id.to_s
|
|
663
|
+
experiences.find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == target }
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# True for +nil+ or a (post-+#strip+) empty String — the blank-input guard
|
|
667
|
+
# for {#set_preview}'s +experience_id+/+variation_id+ (mirrors
|
|
668
|
+
# {Client#create_context}'s blank +visitor_id+ guard).
|
|
669
|
+
def blank?(value)
|
|
670
|
+
value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
|
|
671
|
+
end
|
|
672
|
+
|
|
436
673
|
# The single conversion seam (mirrors {#fire_bucketing}): enqueue the
|
|
437
674
|
# wire-shaped event THEN fire the lifecycle event with +deferred: true+ (late
|
|
438
675
|
# subscribers replay — JS context.ts:416-424). Fired on SUCCESS only (the
|
|
@@ -491,12 +728,21 @@ module ConvertSdk
|
|
|
491
728
|
# it never defaults location matching to the visitor properties) — supplied
|
|
492
729
|
# only when the caller passes +location_properties+/+"location_properties"+.
|
|
493
730
|
# +environment+ is lifted out so the flow's environment-match step sees it.
|
|
731
|
+
#
|
|
732
|
+
# +enable_storage: @preview.nil?+ (qs-03 / RB-6 zero-trace) rides along on
|
|
733
|
+
# EVERY decision built through this ONE seam — {#run_experience},
|
|
734
|
+
# {#run_experiences}, {#run_feature}, and {#run_features} all call it — so a
|
|
735
|
+
# preview-active context's ENTIRE decisioning surface (not just experiences)
|
|
736
|
+
# never persists sticky StoreData ({DataManager#persist_bucketing}'s gate).
|
|
737
|
+
# Absent preview (the overwhelming default), this is always +true+ —
|
|
738
|
+
# byte-identical to the pre-qs-03 behavior.
|
|
494
739
|
def decision_attributes(per_call)
|
|
495
740
|
merged = @attributes.merge(deep_stringify(per_call || {}))
|
|
496
741
|
{
|
|
497
742
|
visitor_properties: merged,
|
|
498
743
|
location_properties: merged["location_properties"],
|
|
499
|
-
environment: merged["environment"]
|
|
744
|
+
environment: merged["environment"],
|
|
745
|
+
enable_storage: @preview.nil?
|
|
500
746
|
}
|
|
501
747
|
end
|
|
502
748
|
|
|
@@ -514,20 +760,26 @@ module ConvertSdk
|
|
|
514
760
|
# wire entry omits the +segments+ key entirely.
|
|
515
761
|
#
|
|
516
762
|
# The SOLE bucketing enqueue site. The {SystemEvents::BUCKETING} LIFECYCLE event
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
763
|
+
# fires for every fresh/decided variation on a NON-preview context (decisioning
|
|
764
|
+
# observability, not tracking — a host listener may need to react to the
|
|
765
|
+
# decision even under consent denial); a preview-active context (qs-03,
|
|
766
|
+
# +@preview+ set) fires NO {SystemEvents::BUCKETING} event for ANY
|
|
767
|
+
# experience on this context, matching JS +context.ts:260+
|
|
768
|
+
# (+if (!this._preview) { fire BUCKETING }+). Independently, the outbound
|
|
769
|
+
# ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
|
|
520
770
|
# composed verdict ({#tracking_enabled_for_call?} — global AND per-call); when +false+
|
|
521
771
|
# the wire enqueue is suppressed with a +debug+ line and stickiness/decisioning
|
|
522
772
|
# are untouched. Contained — a raising listener never crosses back (EventManager
|
|
523
773
|
# swallows it); the enqueue is pure in-memory and inert when no ApiManager is wired.
|
|
524
774
|
def fire_bucketing(experience_key, variation, track: true)
|
|
525
|
-
@
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
775
|
+
if @preview.nil?
|
|
776
|
+
@event_manager.fire(
|
|
777
|
+
SystemEvents::BUCKETING,
|
|
778
|
+
{ visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
|
|
779
|
+
nil,
|
|
780
|
+
deferred: true
|
|
781
|
+
)
|
|
782
|
+
end
|
|
531
783
|
return if suppress_bucketing_enqueue?(track)
|
|
532
784
|
|
|
533
785
|
enqueue_bucketing_event(variation)
|