convert_sdk 1.0.1 → 2.1.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/RELEASE.md +6 -4
- 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 +358 -30
- data/lib/convert_sdk/data_manager.rb +158 -15
- data/lib/convert_sdk/feature_manager.rb +18 -10
- 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
|
@@ -46,6 +46,29 @@ module ConvertSdk
|
|
|
46
46
|
# (+nil+ for lookups, +self+ for the chainable mutator). A raising collaborator
|
|
47
47
|
# degrades the call; it never crashes the host request.
|
|
48
48
|
class Context
|
|
49
|
+
# The reserved keys the public per-call hash accepts (CAP-3): a +:merged_map+ row
|
|
50
|
+
# lands in the engine envelope at +destination+, a +:raw_per_call+ row in that reader.
|
|
51
|
+
RESERVED_KEYS = {
|
|
52
|
+
"location_properties" => { source: :merged_map, destination: :location_properties,
|
|
53
|
+
scope: "every decision entry point" },
|
|
54
|
+
"environment" => { source: :merged_map, destination: :environment,
|
|
55
|
+
scope: "every decision entry point" },
|
|
56
|
+
"enable_tracking" => { source: :raw_per_call, destination: :tracking_enabled_for_call,
|
|
57
|
+
scope: "honoured on run_experience(s), accepted inert on run_feature(s)" },
|
|
58
|
+
"experience_keys" => { source: :raw_per_call, destination: :experiences,
|
|
59
|
+
scope: "run_feature(s) only; narrows the experiences decided (CAP-1)" },
|
|
60
|
+
"type_casting" => { source: :raw_per_call, destination: :type_casting,
|
|
61
|
+
scope: "run_feature(s) only; false returns the config-stored variables (CAP-2)" },
|
|
62
|
+
"ruleData" => { source: :raw_per_call, destination: :visitor_properties,
|
|
63
|
+
scope: "run_custom_segments only; camelCase on a snake_case surface (SD-4)" }
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# Engine-readable keys the seam never lifts, each with its reason (CAP-3's negative list).
|
|
67
|
+
NOT_LIFTED = {
|
|
68
|
+
"enable_storage" => "preview owns the persistence gate (D-4)",
|
|
69
|
+
"update_visitor_properties" => "documented Ruby divergence (D-5)"
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
49
72
|
# @param visitor_id [String] the resolved visitor id (validated non-blank by
|
|
50
73
|
# {Client#create_context} before construction).
|
|
51
74
|
# @param attributes [Hash, nil] the per-visitor attributes; deep-stringified
|
|
@@ -86,6 +109,11 @@ module ConvertSdk
|
|
|
86
109
|
# Deep-stringify the caller's attributes ONCE at the boundary; internals
|
|
87
110
|
# only ever see string keys. nil → empty. The caller's hash is never mutated.
|
|
88
111
|
@attributes = deep_stringify(attributes || {})
|
|
112
|
+
# qs-03 (RB-5) preview state — nil until {#set_preview} succeeds. A plain
|
|
113
|
+
# per-instance ivar (never a class/shared variable): two Contexts NEVER
|
|
114
|
+
# share this (AC7 isolation). Shape: +{experience_id:, variation_id:,
|
|
115
|
+
# experience:, experience_key:}+.
|
|
116
|
+
@preview = nil #: Hash[Symbol, untyped]?
|
|
89
117
|
end
|
|
90
118
|
|
|
91
119
|
# @return [String] the visitor id this context is bound to.
|
|
@@ -105,12 +133,25 @@ module ConvertSdk
|
|
|
105
133
|
# +{segments: props}+ — +context.ts:482+). The merge is atomic per visitor:
|
|
106
134
|
# the read-modify-write runs inside the store manager's merge mutex.
|
|
107
135
|
#
|
|
136
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
137
|
+
#
|
|
138
|
+
# On a preview-active context ONLY the store write below is skipped — the
|
|
139
|
+
# in-memory +@attributes+ merge always applies (JS parity: JS's public
|
|
140
|
+
# +updateVisitorProperties+ has no preview guard of its own, but the
|
|
141
|
+
# PRIVATE helper it calls to persist DOES skip entirely under preview —
|
|
142
|
+
# +context.ts:626-629+, +if (this._preview) return;+ — leaving the
|
|
143
|
+
# in-memory side unaffected there too). "Per-context scratch" per the
|
|
144
|
+
# qs-03 spec: a later decision on THIS context still sees the merge; no
|
|
145
|
+
# trace of it ever reaches the store.
|
|
146
|
+
#
|
|
108
147
|
# @param properties [Hash] the properties to merge (symbol or string keys).
|
|
109
148
|
# @return [self]
|
|
110
149
|
def update_visitor_properties(properties)
|
|
111
150
|
normalised = deep_stringify(properties || {})
|
|
112
|
-
@
|
|
113
|
-
|
|
151
|
+
if @preview.nil?
|
|
152
|
+
@data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
|
|
153
|
+
{ "segments" => normalised }
|
|
154
|
+
end
|
|
114
155
|
end
|
|
115
156
|
@attributes = @attributes.merge(normalised)
|
|
116
157
|
self
|
|
@@ -170,6 +211,79 @@ module ConvertSdk
|
|
|
170
211
|
nil
|
|
171
212
|
end
|
|
172
213
|
|
|
214
|
+
# Force a specific variation of an experience for THIS context — bypassing
|
|
215
|
+
# audiences, segments, locations, the environment check, experience status,
|
|
216
|
+
# variation status/traffic filters, stored decisions, and the bucketing
|
|
217
|
+
# hash for that experience only (qs-03 AC4/AC5). Mirrors JS
|
|
218
|
+
# +Context#setPreview+ (+context.ts:143-205+); this Ruby surface is
|
|
219
|
+
# synchronous — the +?exp=+ fallback fetch runs through
|
|
220
|
+
# {ApiManager#get_config_by_experience}, itself process-wide memoized for
|
|
221
|
+
# 60s (qs-03 AC8), so no async/await equivalent is needed here.
|
|
222
|
+
#
|
|
223
|
+
# == Resolution
|
|
224
|
+
#
|
|
225
|
+
# The CURRENT installed config is tried first, by id
|
|
226
|
+
# ({DataManager#experience_by_id}); when absent, a live
|
|
227
|
+
# +?exp={experience_id}+ fetch resolves it instead (never touches the
|
|
228
|
+
# installed config or the store — a previewed experience may be draft/
|
|
229
|
+
# paused and must never be cached alongside production config). The
|
|
230
|
+
# resolved experience is held BY REFERENCE, never duped or rebuilt:
|
|
231
|
+
# DataManager's installed config entities are already deep-frozen (Story
|
|
232
|
+
# 2.7), so simply holding the reference carries none of the JS SDK-7
|
|
233
|
+
# in-place-mutation risk (mutating a frozen Ruby Hash raises
|
|
234
|
+
# +FrozenError+ rather than silently corrupting shared state); a fetched
|
|
235
|
+
# experience is a brand-new object that is never installed anywhere, so it
|
|
236
|
+
# is never shared to begin with.
|
|
237
|
+
#
|
|
238
|
+
# == Inert on bad input (AC7)
|
|
239
|
+
#
|
|
240
|
+
# A blank +experience_id+/+variation_id+, an unresolvable experience
|
|
241
|
+
# (absent from both the installed config and the +?exp=+ fetch response),
|
|
242
|
+
# or an unknown +variation_id+ on the resolved experience all leave preview
|
|
243
|
+
# state UNSET (a +warn+ log, never a raise) — a subsequent
|
|
244
|
+
# {#run_experience} on this context decides exactly as if this method had
|
|
245
|
+
# never been called.
|
|
246
|
+
#
|
|
247
|
+
# == Isolation (AC7)
|
|
248
|
+
#
|
|
249
|
+
# Preview state lives on THIS +Context+ instance only (a plain ivar) — two
|
|
250
|
+
# +Context+s, even for the same client/config, never share it.
|
|
251
|
+
#
|
|
252
|
+
# Never raises into the host: an internal failure degrades to an +error+
|
|
253
|
+
# log + leaves preview state unset (NFR9).
|
|
254
|
+
#
|
|
255
|
+
# @param experience_id [String] the previewed experience's +id+.
|
|
256
|
+
# @param variation_id [String] the variation +id+ to force.
|
|
257
|
+
# @return [self]
|
|
258
|
+
def set_preview(experience_id:, variation_id:)
|
|
259
|
+
return warn_preview_inert("experience_id/variation_id required") if blank?(experience_id) || blank?(variation_id)
|
|
260
|
+
|
|
261
|
+
# Coerce ONCE at this public entry (review round 2) — ids may arrive as
|
|
262
|
+
# different types (an Integer id read straight off a link param, a
|
|
263
|
+
# String elsewhere); every downstream reference (resolution, the
|
|
264
|
+
# decision lookup, the stored @preview hash) threads these coerced
|
|
265
|
+
# locals instead of the original keyword args, mirroring
|
|
266
|
+
# {#fetch_preview_experience}'s existing "ids may arrive as different
|
|
267
|
+
# types" +.to_s+ convention.
|
|
268
|
+
exp_id = experience_id.to_s
|
|
269
|
+
var_id = variation_id.to_s
|
|
270
|
+
|
|
271
|
+
experience = resolve_preview_experience(exp_id)
|
|
272
|
+
return warn_preview_inert("no experience found for id=#{exp_id}", clear: true) if experience.nil?
|
|
273
|
+
|
|
274
|
+
decision = @data_manager.get_preview_decision(experience, var_id)
|
|
275
|
+
return warn_preview_inert("no variation found for id=#{var_id}", clear: true) if decision.nil?
|
|
276
|
+
|
|
277
|
+
@preview = {
|
|
278
|
+
experience_id: exp_id, variation_id: var_id,
|
|
279
|
+
experience: experience, experience_key: experience["key"]
|
|
280
|
+
}
|
|
281
|
+
self
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
@log_manager.error("Context#set_preview: #{e.class}: #{e.message}")
|
|
284
|
+
self
|
|
285
|
+
end
|
|
286
|
+
|
|
173
287
|
# Decide a single experience for this visitor and return its variation.
|
|
174
288
|
#
|
|
175
289
|
# The optional per-call +attributes+ are deep-stringified and merged OVER the
|
|
@@ -197,6 +311,29 @@ module ConvertSdk
|
|
|
197
311
|
# NO bucketing event is enqueued (a +debug+ line records the suppression). The
|
|
198
312
|
# global Config +tracking: false+ switch ALWAYS wins over a per-call +true+.
|
|
199
313
|
#
|
|
314
|
+
# == Preview forcing (qs-03 AC4/AC5)
|
|
315
|
+
#
|
|
316
|
+
# When {#set_preview} has forced a variation for THIS +key+ on this context,
|
|
317
|
+
# that forced decision is returned DIRECTLY — bypassing decisioning entirely
|
|
318
|
+
# (no audience/location/environment/status/traffic/stored-decision/bucketing
|
|
319
|
+
# walk) and firing NO {SystemEvents::BUCKETING} event (mirrors JS
|
|
320
|
+
# +context.ts:228-235+). Because this check runs FIRST, it naturally takes
|
|
321
|
+
# precedence over a stored decision or fresh bucketing for that experience;
|
|
322
|
+
# every OTHER experience on this same context still decides normally.
|
|
323
|
+
#
|
|
324
|
+
# == Zero-trace on OTHER experiences under preview (qs-03 AC6, RB-6)
|
|
325
|
+
#
|
|
326
|
+
# When THIS context has a preview active but +key+ is NOT the previewed
|
|
327
|
+
# experience, decisioning proceeds NORMALLY — only tracking/persistence are
|
|
328
|
+
# suppressed: {#decision_attributes} threads +enable_storage: false+ so
|
|
329
|
+
# {DataManager#persist_bucketing} never writes sticky StoreData, and the
|
|
330
|
+
# per-call tracking verdict is forced +false+ so {#fire_bucketing}'s
|
|
331
|
+
# {#suppress_bucketing_enqueue?} skips the outbound enqueue.
|
|
332
|
+
# {#fire_bucketing} ALSO suppresses the in-process {SystemEvents::BUCKETING}
|
|
333
|
+
# lifecycle-event fire while a preview is active on this context (JS parity
|
|
334
|
+
# — +context.ts:260+: +if (!this._preview) { fire BUCKETING }+). No event
|
|
335
|
+
# fires for any experience on a preview-active context.
|
|
336
|
+
#
|
|
200
337
|
# @param key [String] the experience +key+.
|
|
201
338
|
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
202
339
|
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
@@ -205,9 +342,13 @@ module ConvertSdk
|
|
|
205
342
|
manager = @experience_manager
|
|
206
343
|
return RuleError::NO_DATA_FOUND if manager.nil?
|
|
207
344
|
|
|
345
|
+
preview = @preview
|
|
346
|
+
return forced_preview_variation(preview) if preview && key == preview[:experience_key]
|
|
347
|
+
|
|
208
348
|
@data_manager.ensure_fresh_config!
|
|
209
349
|
variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes))
|
|
210
|
-
|
|
350
|
+
track = preview.nil? && tracking_enabled_for_call?(attributes)
|
|
351
|
+
fire_bucketing(key, variation, track: track) unless variation.is_a?(Sentinel)
|
|
211
352
|
variation
|
|
212
353
|
rescue StandardError => e
|
|
213
354
|
@log_manager.error("Context#run_experience: #{e.class}: #{e.message}")
|
|
@@ -230,6 +371,22 @@ module ConvertSdk
|
|
|
230
371
|
# enqueue for THIS call (decisioning + sticky writes unaffected); the global
|
|
231
372
|
# Config +tracking: false+ switch always wins (Story 4.5).
|
|
232
373
|
#
|
|
374
|
+
# On a preview-active context (qs-03 AC6, RB-6) the previewed experience is
|
|
375
|
+
# FORCED to its preview variation here too — run-all forcing is API-agnostic,
|
|
376
|
+
# matching {#run_experience} and the PHP/Android/Python/iOS SDKs. Its
|
|
377
|
+
# normally-decided entry is dropped and the forced variation appended (or
|
|
378
|
+
# appended outright when the previewed experience is absent from the run-all
|
|
379
|
+
# set, e.g. a draft resolved only via +?exp=+); the forced entry fires no
|
|
380
|
+
# {SystemEvents::BUCKETING} event, exactly as {#run_experience}'s forced
|
|
381
|
+
# branch returns before {#fire_bucketing}. Every OTHER decided variation is
|
|
382
|
+
# zero-trace like {#run_experience}'s other-experience branch:
|
|
383
|
+
# {#decision_attributes} suppresses the sticky persist, the tracking verdict
|
|
384
|
+
# is forced +false+ so the outbound enqueue is skipped, and
|
|
385
|
+
# {#fire_bucketing}'s +@preview.nil?+ guard suppresses the
|
|
386
|
+
# {SystemEvents::BUCKETING} event as well — NO event fires for ANY
|
|
387
|
+
# experience on a preview-active context (JS parity — +context.ts:260+:
|
|
388
|
+
# +if (!this._preview) { fire BUCKETING }+).
|
|
389
|
+
#
|
|
233
390
|
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
234
391
|
# over the context attributes (deep-stringified). May carry +:enable_tracking+.
|
|
235
392
|
# @return [Array<BucketedVariation>] the frozen variations (misses excluded).
|
|
@@ -239,6 +396,9 @@ module ConvertSdk
|
|
|
239
396
|
|
|
240
397
|
@data_manager.ensure_fresh_config!
|
|
241
398
|
variations = manager.select_variations(@visitor_id, decision_attributes(attributes))
|
|
399
|
+
preview = @preview
|
|
400
|
+
return force_preview_in_run_all(variations, preview) if preview
|
|
401
|
+
|
|
242
402
|
track = tracking_enabled_for_call?(attributes)
|
|
243
403
|
variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) }
|
|
244
404
|
variations
|
|
@@ -268,23 +428,30 @@ module ConvertSdk
|
|
|
268
428
|
# render_legacy_checkout
|
|
269
429
|
# end
|
|
270
430
|
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
431
|
+
# A per-call +experience_keys+ Array narrows which experiences are decided,
|
|
432
|
+
# and so which sticky assignments the read commits (CAP-1); absent, nil or
|
|
433
|
+
# empty decides every configured experience (D-6).
|
|
434
|
+
# A per-call +type_casting+ of +false+ returns variables as config stores them (CAP-2).
|
|
435
|
+
#
|
|
436
|
+
# NOTE (accepted parity break): +type_casting: nil+ leaves casting ON here,
|
|
437
|
+
# where the JS presence-based rule would disable it (D-8).
|
|
274
438
|
#
|
|
275
439
|
# Never raises into the host: an internal failure degrades to a DISABLED
|
|
276
440
|
# {BucketedFeature} (carrying the requested key) + an +error+ log (NFR9).
|
|
277
441
|
#
|
|
278
442
|
# @param key [String] the feature +key+ to evaluate.
|
|
279
|
-
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
280
|
-
#
|
|
443
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged over
|
|
444
|
+
# the context attributes (deep-stringified). May carry +:experience_keys+
|
|
445
|
+
# (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
|
|
281
446
|
# @return [BucketedFeature, Array<BucketedFeature>] the resolved feature(s).
|
|
282
447
|
def run_feature(key, attributes = nil)
|
|
283
448
|
manager = @feature_manager
|
|
284
449
|
return disabled_feature(key) if manager.nil?
|
|
285
450
|
|
|
286
451
|
@data_manager.ensure_fresh_config!
|
|
287
|
-
manager.run_feature(@visitor_id, key, decision_attributes(attributes)
|
|
452
|
+
manager.run_feature(@visitor_id, key, decision_attributes(attributes),
|
|
453
|
+
experiences: experience_keys_for_call(attributes),
|
|
454
|
+
type_casting: type_casting_for_call?(attributes))
|
|
288
455
|
rescue StandardError => e
|
|
289
456
|
@log_manager.error("Context#run_feature: #{e.class}: #{e.message}")
|
|
290
457
|
disabled_feature(key)
|
|
@@ -303,15 +470,18 @@ module ConvertSdk
|
|
|
303
470
|
# Never raises into the host: an internal failure degrades to +[]+ + an
|
|
304
471
|
# +error+ log (NFR9).
|
|
305
472
|
#
|
|
306
|
-
# @param attributes [Hash, nil] optional per-call visitor properties merged
|
|
307
|
-
#
|
|
473
|
+
# @param attributes [Hash, nil] optional per-call visitor properties merged over
|
|
474
|
+
# the context attributes (deep-stringified). May carry +:experience_keys+
|
|
475
|
+
# (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
|
|
308
476
|
# @return [Array<BucketedFeature>] the resolved features (enabled + disabled).
|
|
309
477
|
def run_features(attributes = nil)
|
|
310
478
|
manager = @feature_manager
|
|
311
479
|
return [] if manager.nil?
|
|
312
480
|
|
|
313
481
|
@data_manager.ensure_fresh_config!
|
|
314
|
-
manager.run_features(@visitor_id, decision_attributes(attributes)
|
|
482
|
+
manager.run_features(@visitor_id, decision_attributes(attributes),
|
|
483
|
+
experiences: experience_keys_for_call(attributes),
|
|
484
|
+
type_casting: type_casting_for_call?(attributes))
|
|
315
485
|
rescue StandardError => e
|
|
316
486
|
@log_manager.error("Context#run_features: #{e.class}: #{e.message}")
|
|
317
487
|
[]
|
|
@@ -328,6 +498,12 @@ module ConvertSdk
|
|
|
328
498
|
# NO lifecycle event fires on segment attachment (JS parity — neither
|
|
329
499
|
# +setDefaultSegments+ nor +runCustomSegments+ fire +SystemEvents.SEGMENTS+).
|
|
330
500
|
#
|
|
501
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
502
|
+
#
|
|
503
|
+
# +enable_storage: @preview.nil?+ threads through to
|
|
504
|
+
# {SegmentsManager#put_segments}, suppressing ONLY the persistence write
|
|
505
|
+
# (mirrors JS SDK-6, +context.ts:571+ — +!this._preview+ passed the same way).
|
|
506
|
+
#
|
|
331
507
|
# Never raises into the host: a failure degrades to an +error+ log and returns
|
|
332
508
|
# +self+ (NFR9).
|
|
333
509
|
#
|
|
@@ -337,7 +513,7 @@ module ConvertSdk
|
|
|
337
513
|
manager = @segments_manager
|
|
338
514
|
return self if manager.nil?
|
|
339
515
|
|
|
340
|
-
manager.put_segments(@visitor_id, deep_stringify(segments || {}))
|
|
516
|
+
manager.put_segments(@visitor_id, deep_stringify(segments || {}), enable_storage: @preview.nil?)
|
|
341
517
|
self
|
|
342
518
|
rescue StandardError => e
|
|
343
519
|
@log_manager.error("Context#set_default_segments: #{e.class}: #{e.message}")
|
|
@@ -355,6 +531,13 @@ module ConvertSdk
|
|
|
355
531
|
#
|
|
356
532
|
# NO lifecycle event fires on attachment (JS parity, F-014).
|
|
357
533
|
#
|
|
534
|
+
# == Zero-trace under preview (qs-03 AC6, RB-6)
|
|
535
|
+
#
|
|
536
|
+
# +enable_storage: @preview.nil?+ threads through to
|
|
537
|
+
# {SegmentsManager#select_custom_segments}, suppressing ONLY the persistence
|
|
538
|
+
# write — rule MATCHING still runs exactly as normal (mirrors JS SDK-6,
|
|
539
|
+
# +context.ts:610+ — +!this._preview+ passed the same way).
|
|
540
|
+
#
|
|
358
541
|
# Never raises into the host: a failure degrades to an +error+ log + +nil+ (NFR9).
|
|
359
542
|
#
|
|
360
543
|
# @param segment_keys [Array<String>] the segment keys to evaluate.
|
|
@@ -366,7 +549,9 @@ module ConvertSdk
|
|
|
366
549
|
manager = @segments_manager
|
|
367
550
|
return nil if manager.nil?
|
|
368
551
|
|
|
369
|
-
result = manager.select_custom_segments(
|
|
552
|
+
result = manager.select_custom_segments(
|
|
553
|
+
@visitor_id, segment_keys, visitor_properties(attributes), enable_storage: @preview.nil?
|
|
554
|
+
)
|
|
370
555
|
result.is_a?(Sentinel) ? result : nil
|
|
371
556
|
rescue StandardError => e
|
|
372
557
|
@log_manager.error("Context#run_custom_segments: #{e.class}: #{e.message}")
|
|
@@ -408,6 +593,14 @@ module ConvertSdk
|
|
|
408
593
|
# @param force_multiple_transactions [Boolean] bypass the per-goal dedup check.
|
|
409
594
|
# @return [self]
|
|
410
595
|
def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false)
|
|
596
|
+
# qs-03 (RB-6) — zero-trace: a preview-active context is a FULL no-op here,
|
|
597
|
+
# checked BEFORE the global tracking gate below (and BEFORE
|
|
598
|
+
# DataManager#convert) so NOTHING happens — no enqueue, no CONVERSION
|
|
599
|
+
# event, and no dedup mark (the mark lives inside #convert's atomic
|
|
600
|
+
# dedup-and-mark, never reached). Mirrors JS +context.ts:512-519+
|
|
601
|
+
# (+if (this._preview) return;+ at the top of +trackConversion+).
|
|
602
|
+
return self if @preview
|
|
603
|
+
|
|
411
604
|
# Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a
|
|
412
605
|
# suppressed conversion neither enqueues NOR marks dedup (the goals[goalId]
|
|
413
606
|
# mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal
|
|
@@ -433,6 +626,91 @@ module ConvertSdk
|
|
|
433
626
|
|
|
434
627
|
private
|
|
435
628
|
|
|
629
|
+
# {#set_preview}'s inert-path helper: warn-log +detail+ under the
|
|
630
|
+
# +Context#set_preview+ prefix (the single log-message shape every inert
|
|
631
|
+
# branch shares — AC7) and return +self+.
|
|
632
|
+
#
|
|
633
|
+
# +clear:+ mirrors JS +Context#setPreview+ (+context.ts:143-205+) exactly:
|
|
634
|
+
# the BLANK-input guard (this method's caller with +clear: false+, the
|
|
635
|
+
# default) leaves a prior successful +@preview+ untouched — JS's own
|
|
636
|
+
# +if (!experienceId || !variationId)+ branch returns without touching
|
|
637
|
+
# +this._preview+ (+context.ts:146-152+). Every OTHER failure path — an
|
|
638
|
+
# unresolvable experience (+context.ts:172,182+) or an unknown variation id
|
|
639
|
+
# on the resolved experience (+context.ts:195+) — explicitly nulls the
|
|
640
|
+
# preview (JS: +this._preview = null;+ before each of those returns), so a
|
|
641
|
+
# FAILED re-preview after a prior success falls back to normal decisioning
|
|
642
|
+
# rather than stranding the stale forced pick. Callers pass +clear: true+
|
|
643
|
+
# for those two paths only.
|
|
644
|
+
def warn_preview_inert(detail, clear: false)
|
|
645
|
+
@preview = nil if clear
|
|
646
|
+
@log_manager.warn("Context#set_preview: #{detail}")
|
|
647
|
+
self
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# {#run_experience}'s preview-forcing branch: force-decide THIS visitor's
|
|
651
|
+
# previewed variation via {DataManager#get_preview_decision}. +#set_preview+
|
|
652
|
+
# only ever stores a preview whose (experience, variation_id) pair already
|
|
653
|
+
# resolved a decision, and the resolved experience is a frozen Hash that can
|
|
654
|
+
# never drift afterward, so a nil result here is a defensive fallback, not
|
|
655
|
+
# an expected path.
|
|
656
|
+
def forced_preview_variation(preview)
|
|
657
|
+
@data_manager.get_preview_decision(preview[:experience], preview[:variation_id]) || RuleError::NO_DATA_FOUND
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# {#run_experiences}'s preview branch, extracted to keep #run_experiences
|
|
661
|
+
# within RuboCop's ABC/complexity budget: drop the previewed experience's
|
|
662
|
+
# normally-decided entry (the forced decision replaces it), route every
|
|
663
|
+
# OTHER experience through {#fire_bucketing} with +track: false+, then
|
|
664
|
+
# append the forced variation. On a preview-active context that
|
|
665
|
+
# {#fire_bucketing} call is inert BY DESIGN — its +@preview.nil?+ guard
|
|
666
|
+
# suppresses the {SystemEvents::BUCKETING} event and +track: false+
|
|
667
|
+
# suppresses the enqueue, so all it emits is the +debug+ suppression line.
|
|
668
|
+
# The seam is kept rather than skipped so the zero-trace verdict stays
|
|
669
|
+
# enforced at the SINGLE bucketing site instead of being duplicated here.
|
|
670
|
+
# A defensive Sentinel from {#forced_preview_variation} (set_preview
|
|
671
|
+
# pre-validates, so not expected) appends nothing.
|
|
672
|
+
def force_preview_in_run_all(variations, preview)
|
|
673
|
+
others = variations.reject { |variation| variation.experience_key == preview[:experience_key] }
|
|
674
|
+
others.each { |variation| fire_bucketing(variation.experience_key, variation, track: false) }
|
|
675
|
+
forced = forced_preview_variation(preview)
|
|
676
|
+
forced.is_a?(Sentinel) ? others : others + [forced]
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Resolve the previewed experience for {#set_preview}: the installed
|
|
680
|
+
# config's by-id reader first ({DataManager#experience_by_id}); when
|
|
681
|
+
# absent, the +?exp=+ live-fetch fallback ({#fetch_preview_experience}).
|
|
682
|
+
def resolve_preview_experience(experience_id)
|
|
683
|
+
@data_manager.experience_by_id(experience_id) || fetch_preview_experience(experience_id)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# The +?exp={experience_id}+ fallback fetch ({ApiManager#get_config_by_experience},
|
|
687
|
+
# process-wide memoized for 60s — qs-03 AC8) for an experience absent from
|
|
688
|
+
# the installed config (e.g. a draft/paused preview target). A nil
|
|
689
|
+
# {ApiManager} (no Client-wired collaborator) or a failed fetch (the port
|
|
690
|
+
# never raises — it degrades to nil) both resolve to a miss here; the
|
|
691
|
+
# response's own +experiences+ collection is scanned by id (+to_s+
|
|
692
|
+
# compared, ids may arrive as different types).
|
|
693
|
+
def fetch_preview_experience(experience_id)
|
|
694
|
+
manager = @api_manager
|
|
695
|
+
return nil if manager.nil?
|
|
696
|
+
|
|
697
|
+
config = manager.get_config_by_experience(experience_id)
|
|
698
|
+
return nil unless config.is_a?(Hash)
|
|
699
|
+
|
|
700
|
+
experiences = config["experiences"]
|
|
701
|
+
return nil unless experiences.is_a?(Array)
|
|
702
|
+
|
|
703
|
+
target = experience_id.to_s
|
|
704
|
+
experiences.find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == target }
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# True for +nil+ or a (post-+#strip+) empty String — the blank-input guard
|
|
708
|
+
# for {#set_preview}'s +experience_id+/+variation_id+ (mirrors
|
|
709
|
+
# {Client#create_context}'s blank +visitor_id+ guard).
|
|
710
|
+
def blank?(value)
|
|
711
|
+
value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
|
|
712
|
+
end
|
|
713
|
+
|
|
436
714
|
# The single conversion seam (mirrors {#fire_bucketing}): enqueue the
|
|
437
715
|
# wire-shaped event THEN fire the lifecycle event with +deferred: true+ (late
|
|
438
716
|
# subscribers replay — JS context.ts:416-424). Fired on SUCCESS only (the
|
|
@@ -469,7 +747,8 @@ module ConvertSdk
|
|
|
469
747
|
# per-call +ruleData+ (and context attributes) win over stored segments. All
|
|
470
748
|
# deep-stringified to string keys (the rule engine reads string keys).
|
|
471
749
|
def visitor_properties(attributes)
|
|
472
|
-
|
|
750
|
+
key = reserved_key_name(:visitor_properties)&.to_s
|
|
751
|
+
rule_data = key && attributes.is_a?(Hash) ? attributes[key.to_sym] || attributes[key] : nil
|
|
473
752
|
empty = {} #: Hash[String, untyped]
|
|
474
753
|
merged = @attributes.merge(deep_stringify(rule_data || empty))
|
|
475
754
|
stored = get_visitor_data["segments"]
|
|
@@ -491,13 +770,53 @@ module ConvertSdk
|
|
|
491
770
|
# it never defaults location matching to the visitor properties) — supplied
|
|
492
771
|
# only when the caller passes +location_properties+/+"location_properties"+.
|
|
493
772
|
# +environment+ is lifted out so the flow's environment-match step sees it.
|
|
773
|
+
#
|
|
774
|
+
# +enable_storage: @preview.nil?+ (qs-03 / RB-6 zero-trace) rides along on
|
|
775
|
+
# EVERY decision built through this ONE seam — {#run_experience},
|
|
776
|
+
# {#run_experiences}, {#run_feature}, and {#run_features} all call it — so a
|
|
777
|
+
# preview-active context's ENTIRE decisioning surface (not just experiences)
|
|
778
|
+
# never persists sticky StoreData ({DataManager#persist_bucketing}'s gate).
|
|
779
|
+
# Absent preview (the overwhelming default), this is always +true+ —
|
|
780
|
+
# byte-identical to the pre-qs-03 behavior.
|
|
494
781
|
def decision_attributes(per_call)
|
|
495
782
|
merged = @attributes.merge(deep_stringify(per_call || {}))
|
|
496
|
-
{
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
783
|
+
envelope = { visitor_properties: merged } #: Hash[Symbol, untyped]
|
|
784
|
+
reserved_rows(:merged_map).each { |name, fields| envelope[fields[:destination]] = merged[name.to_s] }
|
|
785
|
+
envelope[:enable_storage] = @preview.nil?
|
|
786
|
+
envelope
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def reserved_rows(source)
|
|
790
|
+
RESERVED_KEYS.reject { |name, fields| NOT_LIFTED.key?(name.to_s) || fields[:source] != source }
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The per-call key name the enumeration routes to +destination+, nil when none does.
|
|
794
|
+
def reserved_key_name(destination)
|
|
795
|
+
row = reserved_rows(:raw_per_call).find { |_, fields| fields[:destination] == destination }
|
|
796
|
+
row&.first
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# The per-call experience-key filter (CAP-1). A non-Array value degrades to
|
|
800
|
+
# no filter with a +warn+ (SD-2); nil is absence and never warns.
|
|
801
|
+
def experience_keys_for_call(attributes)
|
|
802
|
+
key = reserved_key_name(:experiences)&.to_s
|
|
803
|
+
return nil unless key && attributes.is_a?(Hash)
|
|
804
|
+
|
|
805
|
+
value = attributes.fetch(key.to_sym) { attributes.fetch(key, nil) }
|
|
806
|
+
return value if value.nil? || value.is_a?(Array)
|
|
807
|
+
|
|
808
|
+
@log_manager.warn("Context#run_feature: #{key} must be an Array, got #{value.class} — ignoring it")
|
|
809
|
+
nil
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# The per-call casting switch (CAP-2): only an explicit +false+ turns it off (D-8).
|
|
813
|
+
def type_casting_for_call?(attributes)
|
|
814
|
+
return true unless attributes.is_a?(Hash)
|
|
815
|
+
|
|
816
|
+
key = reserved_key_name(:type_casting)&.to_s
|
|
817
|
+
return true if key.nil?
|
|
818
|
+
|
|
819
|
+
attributes.fetch(key.to_sym) { attributes.fetch(key, true) } != false
|
|
501
820
|
end
|
|
502
821
|
|
|
503
822
|
# The single named seam fired once per fresh/decided variation. It does TWO
|
|
@@ -514,20 +833,26 @@ module ConvertSdk
|
|
|
514
833
|
# wire entry omits the +segments+ key entirely.
|
|
515
834
|
#
|
|
516
835
|
# The SOLE bucketing enqueue site. The {SystemEvents::BUCKETING} LIFECYCLE event
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
836
|
+
# fires for every fresh/decided variation on a NON-preview context (decisioning
|
|
837
|
+
# observability, not tracking — a host listener may need to react to the
|
|
838
|
+
# decision even under consent denial); a preview-active context (qs-03,
|
|
839
|
+
# +@preview+ set) fires NO {SystemEvents::BUCKETING} event for ANY
|
|
840
|
+
# experience on this context, matching JS +context.ts:260+
|
|
841
|
+
# (+if (!this._preview) { fire BUCKETING }+). Independently, the outbound
|
|
842
|
+
# ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
|
|
520
843
|
# composed verdict ({#tracking_enabled_for_call?} — global AND per-call); when +false+
|
|
521
844
|
# the wire enqueue is suppressed with a +debug+ line and stickiness/decisioning
|
|
522
845
|
# are untouched. Contained — a raising listener never crosses back (EventManager
|
|
523
846
|
# swallows it); the enqueue is pure in-memory and inert when no ApiManager is wired.
|
|
524
847
|
def fire_bucketing(experience_key, variation, track: true)
|
|
525
|
-
@
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
848
|
+
if @preview.nil?
|
|
849
|
+
@event_manager.fire(
|
|
850
|
+
SystemEvents::BUCKETING,
|
|
851
|
+
{ visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
|
|
852
|
+
nil,
|
|
853
|
+
deferred: true
|
|
854
|
+
)
|
|
855
|
+
end
|
|
531
856
|
return if suppress_bucketing_enqueue?(track)
|
|
532
857
|
|
|
533
858
|
enqueue_bucketing_event(variation)
|
|
@@ -557,7 +882,10 @@ module ConvertSdk
|
|
|
557
882
|
def tracking_enabled_for_call?(attributes)
|
|
558
883
|
return true unless attributes.is_a?(Hash)
|
|
559
884
|
|
|
560
|
-
|
|
885
|
+
key = reserved_key_name(:tracking_enabled_for_call)&.to_s
|
|
886
|
+
return true if key.nil?
|
|
887
|
+
|
|
888
|
+
value = attributes.fetch(key.to_sym) { attributes.fetch(key, true) }
|
|
561
889
|
value != false
|
|
562
890
|
end
|
|
563
891
|
|