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
|
@@ -92,23 +92,27 @@ module ConvertSdk
|
|
|
92
92
|
# so a Context can supply its own resolution without re-reading config.
|
|
93
93
|
# @param project_resolver [#call, nil] returns the project id for the visitor
|
|
94
94
|
# store key; defaults to {#project_id}.
|
|
95
|
+
# @param config_cache_disabled [Boolean] when true, {#cache_config} is a
|
|
96
|
+
# no-op — the config-cache WRITE is suppressed. Ruby-SDK-only hardening
|
|
97
|
+
# (qs-03, NOT a JS-parity concern): while a +debug_token+ is configured,
|
|
98
|
+
# a shared store (e.g. Redis) must never be poisoned with a
|
|
99
|
+
# debug-widened config that a production reader could later pick up.
|
|
100
|
+
# Sourced by {ConvertSdk.build_data_manager} from +!config.debug_token.nil?+.
|
|
101
|
+
# Defaults to +false+ (cache writes enabled) for standalone construction.
|
|
95
102
|
def initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil,
|
|
96
103
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil,
|
|
97
104
|
bucketing_manager: nil, rule_manager: nil,
|
|
98
|
-
account_resolver: nil, project_resolver: nil)
|
|
105
|
+
account_resolver: nil, project_resolver: nil, config_cache_disabled: false)
|
|
99
106
|
@log_manager = log_manager
|
|
100
107
|
@data_store_manager = data_store_manager
|
|
101
108
|
@config_key = config_key
|
|
102
109
|
@ttl = ttl
|
|
110
|
+
@config_cache_disabled = config_cache_disabled
|
|
103
111
|
# Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured".
|
|
104
112
|
@timer_off = ttl.nil?
|
|
105
113
|
@clock = clock
|
|
106
114
|
@refetch = refetch
|
|
107
|
-
|
|
108
|
-
@bucketing_manager = bucketing_manager
|
|
109
|
-
@rule_manager = rule_manager
|
|
110
|
-
@account_resolver = account_resolver || -> { account_id }
|
|
111
|
-
@project_resolver = project_resolver || -> { project_id }
|
|
115
|
+
assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver)
|
|
112
116
|
# The deep-frozen config envelope, or nil before the first install. Read
|
|
113
117
|
# lock-free by every reader; replaced atomically under @config_mutex.
|
|
114
118
|
@config = nil #: Hash[String, untyped]?
|
|
@@ -291,6 +295,17 @@ module ConvertSdk
|
|
|
291
295
|
find_by_key(experiences, key)
|
|
292
296
|
end
|
|
293
297
|
|
|
298
|
+
# @param id [String] the experience +id+ to find (+to_s+ compared — ids may
|
|
299
|
+
# arrive as Integer or String across the wire, e.g. a caller-supplied
|
|
300
|
+
# +experience_id+ vs. the config's JSON-parsed +id+). Used by the qs-03
|
|
301
|
+
# preview resolution ({Context#set_preview}) to look the previewed
|
|
302
|
+
# experience up against the CURRENT installed config before falling back
|
|
303
|
+
# to the +?exp=+ live fetch.
|
|
304
|
+
# @return [Hash, nil] the frozen experience with that id, or nil.
|
|
305
|
+
def experience_by_id(experience_id)
|
|
306
|
+
find_by_id(experiences, experience_id)
|
|
307
|
+
end
|
|
308
|
+
|
|
294
309
|
# @param key [String] the feature +key+ to find.
|
|
295
310
|
# @return [Hash, nil] the frozen feature with that key, or nil.
|
|
296
311
|
def feature_by_key(key)
|
|
@@ -338,6 +353,32 @@ module ConvertSdk
|
|
|
338
353
|
retrieve_bucketing(visitor_id, experience, attributes)
|
|
339
354
|
end
|
|
340
355
|
|
|
356
|
+
# ============================ PREVIEW (qs-03) ===========================
|
|
357
|
+
# Force-decide a specific variation on a caller-supplied experience,
|
|
358
|
+
# bypassing the ENTIRE decision flow above: audiences, segments, locations,
|
|
359
|
+
# the environment check, experience status, variation status/traffic
|
|
360
|
+
# filters, stored decisions, and the bucketing hash. Mirrors JS
|
|
361
|
+
# data-manager.ts +getPreviewDecision+ (qs-03 / SDK-4).
|
|
362
|
+
#
|
|
363
|
+
# PURE by construction: reads ONLY +experience["variations"]+ (via
|
|
364
|
+
# {#retrieve_variation}) -- never the installed config -- because a preview
|
|
365
|
+
# experience fetched via +?exp=+ may not be registered there. Has ZERO side
|
|
366
|
+
# effects: never calls {#persist_bucketing} / +merge_visitor_data+ and never
|
|
367
|
+
# enqueues anything (this method has no store or transport collaborator to
|
|
368
|
+
# call in the first place).
|
|
369
|
+
#
|
|
370
|
+
# @param experience [Hash] the experience config entity to preview (may be
|
|
371
|
+
# absent from the installed config).
|
|
372
|
+
# @param variation_id [String] the variation id to force (+to_s+ compared).
|
|
373
|
+
# @return [BucketedVariation, nil] the forced decision, or nil when
|
|
374
|
+
# +variation_id+ does not match any variation on +experience+.
|
|
375
|
+
def get_preview_decision(experience, variation_id)
|
|
376
|
+
variation = retrieve_variation(experience, variation_id)
|
|
377
|
+
return nil if variation.nil?
|
|
378
|
+
|
|
379
|
+
build_bucketed_variation(experience, variation, nil)
|
|
380
|
+
end
|
|
381
|
+
|
|
341
382
|
# ========================== CONVERSION TRACKING =========================
|
|
342
383
|
# Track a conversion for +visitor_id+ on +goal_key+ with optional revenue /
|
|
343
384
|
# transaction +goal_data+, applying two-level goal dedup (Story 4.3).
|
|
@@ -393,6 +434,17 @@ module ConvertSdk
|
|
|
393
434
|
|
|
394
435
|
private
|
|
395
436
|
|
|
437
|
+
# Assign the decision-flow collaborators (Story 2.11) and their store-key
|
|
438
|
+
# resolvers — extracted from #initialize to keep it small. Collaborators
|
|
439
|
+
# are config-read-only when absent; resolvers default to this manager's
|
|
440
|
+
# own {#account_id} / {#project_id} readers when not injected.
|
|
441
|
+
def assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver)
|
|
442
|
+
@bucketing_manager = bucketing_manager
|
|
443
|
+
@rule_manager = rule_manager
|
|
444
|
+
@account_resolver = account_resolver || -> { account_id }
|
|
445
|
+
@project_resolver = project_resolver || -> { project_id }
|
|
446
|
+
end
|
|
447
|
+
|
|
396
448
|
# The atomic check-then-mark (Story 4.3 / qs-01). Runs the dedup decision AND
|
|
397
449
|
# the mark inside ONE store-merge block (the merge mutex). Returns true when
|
|
398
450
|
# the caller should enqueue, false when the conversion is deduplicated.
|
|
@@ -476,6 +528,35 @@ module ConvertSdk
|
|
|
476
528
|
bucketing.is_a?(Hash) ? bucketing : {}
|
|
477
529
|
end
|
|
478
530
|
|
|
531
|
+
# qs-04 mutual-exclusion resolver builder: a per-call pure
|
|
532
|
+
# +->(target_experience_key) { true | false | nil }+ closure over
|
|
533
|
+
# +visitor_id+ ONLY (no global state, no cross-visitor memoization — a
|
|
534
|
+
# fresh lambda per {#match_rules_by_field} call) matching
|
|
535
|
+
# {RuleManager#is_rule_matched}'s +resolver:+ contract exactly.
|
|
536
|
+
#
|
|
537
|
+
# Resolution (the qs-04 normative algorithm):
|
|
538
|
+
# target = config experience whose key == target_experience_key
|
|
539
|
+
# bucketed_raw = target exists AND the visitor's stored bucketing map
|
|
540
|
+
# contains an entry for target["id"].to_s
|
|
541
|
+
#
|
|
542
|
+
# Both reads are pure: {#experience_by_key} reads the frozen installed
|
|
543
|
+
# config; {#stored_bucketing_map} reads the visitor's StoreData via the
|
|
544
|
+
# store seam. Neither ever triggers bucketing of the target
|
|
545
|
+
# (BucketingManager is never called here), never writes (no
|
|
546
|
+
# +merge_visitor_data+), and never tracks — the AC5 read-only invariant.
|
|
547
|
+
#
|
|
548
|
+
# @return [Proc] +nil+ from the built lambda means "target key
|
|
549
|
+
# unresolvable" — RuleManager itself logs the AC8 warning naming the key
|
|
550
|
+
# and applies negation around a +bucketed_raw = false+.
|
|
551
|
+
def mutual_exclusion_resolver(visitor_id)
|
|
552
|
+
lambda do |target_experience_key|
|
|
553
|
+
target = experience_by_key(target_experience_key)
|
|
554
|
+
next nil if target.nil?
|
|
555
|
+
|
|
556
|
+
stored_bucketing_map(visitor_id).key?(target["id"].to_s)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
479
560
|
# Steps 1-7: resolve the experience and run every eligibility gate up to (but
|
|
480
561
|
# not including) traffic allocation. Returns the matched experience Hash, a
|
|
481
562
|
# {Sentinel} (a propagated {RuleError} from a rule walk), or +nil+ (a plain
|
|
@@ -495,7 +576,11 @@ module ConvertSdk
|
|
|
495
576
|
return reason_miss(experience, "location not match") unless location_outcome
|
|
496
577
|
|
|
497
578
|
# Step 6 — audiences (permanent skipped when bucketed; transient always).
|
|
498
|
-
|
|
579
|
+
# qs-04: build the mutual-exclusion resolver here (visitor_id is in scope)
|
|
580
|
+
# and thread it down through match_audiences -> matched_audiences ->
|
|
581
|
+
# RuleManager#is_rule_matched — the ONLY site that builds it.
|
|
582
|
+
resolver = mutual_exclusion_resolver(visitor_id)
|
|
583
|
+
audiences_outcome = match_audiences(experience, attributes[:visitor_properties], is_bucketed, resolver)
|
|
499
584
|
return audiences_outcome if audiences_outcome.is_a?(Sentinel)
|
|
500
585
|
|
|
501
586
|
# Step 7 — custom segments. Both must pass to reach variation selection.
|
|
@@ -566,8 +651,7 @@ module ConvertSdk
|
|
|
566
651
|
# No covering bucket OR a drifted-out selected id -> VARIATION_NOT_DECIDED.
|
|
567
652
|
def bucket_fresh(visitor_id, experience, attributes)
|
|
568
653
|
experience_id = experience["id"].to_s
|
|
569
|
-
|
|
570
|
-
decision = @bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
|
|
654
|
+
decision = fresh_bucketing_decision(visitor_id, experience, experience_id)
|
|
571
655
|
variation_id = decision&.fetch(:variation_id, nil)
|
|
572
656
|
variation = variation_id && retrieve_variation(experience, variation_id)
|
|
573
657
|
if variation.nil?
|
|
@@ -580,6 +664,37 @@ module ConvertSdk
|
|
|
580
664
|
build_bucketed_variation(experience, variation, decision&.fetch(:bucketing_allocation, nil))
|
|
581
665
|
end
|
|
582
666
|
|
|
667
|
+
# qs-01 (bucketing contract v12) anchored-vs-packed GATE. Mirrors the JS
|
|
668
|
+
# reference's +Number(experience.version) > 11+ (data-manager.ts:695) via
|
|
669
|
+
# +Float(x, exception: false)+ coercion: a numeric-looking String (e.g.
|
|
670
|
+
# +"12"+) counts as anchored exactly like the JS oracle, not just a
|
|
671
|
+
# genuine +Numeric+. Missing / nil / genuinely non-numeric (e.g.
|
|
672
|
+
# +"twelve"+) / <= 11 all coerce to +nil+ or a value <= 11 and take the
|
|
673
|
+
# EXISTING packed walk, byte-for-byte unchanged (+build_buckets+ +
|
|
674
|
+
# +bucket_for_visitor+, both untouched by this story). The anchored branch
|
|
675
|
+
# is fed the FULL ordered {#variation_list} (inactive arms retained for
|
|
676
|
+
# anchor stability) — never {#build_buckets}, which drops inactive arms
|
|
677
|
+
# for the packed walk.
|
|
678
|
+
#
|
|
679
|
+
# Hard boundary (documented, not fixable in Ruby): +JSON.parse+ collapses
|
|
680
|
+
# an explicit JSON +null+ version and an ABSENT version to the same Ruby
|
|
681
|
+
# +nil+, so this SDK cannot reproduce a JS split between the two (JS:
|
|
682
|
+
# absent -> +undefined+ -> +Number(undefined)+ is +NaN+ -> packed;
|
|
683
|
+
# explicit +null+ -> +Number(null)+ is +0+ -> also packed here, so the two
|
|
684
|
+
# actually agree for this field). Never served in practice regardless.
|
|
685
|
+
def fresh_bucketing_decision(visitor_id, experience, experience_id)
|
|
686
|
+
version = Float(experience["version"], exception: false)
|
|
687
|
+
anchored = !version.nil? && version > 11
|
|
688
|
+
if anchored
|
|
689
|
+
@bucketing_manager&.bucket_for_visitor_anchored(
|
|
690
|
+
variation_list(experience), visitor_id, experience_id: experience_id
|
|
691
|
+
)
|
|
692
|
+
else
|
|
693
|
+
buckets = build_buckets(experience)
|
|
694
|
+
@bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
583
698
|
# True when +experience.id+ is in the archived-experiences list (to_s match).
|
|
584
699
|
def archived?(experience)
|
|
585
700
|
id = experience["id"].to_s
|
|
@@ -653,7 +768,7 @@ module ConvertSdk
|
|
|
653
768
|
# re-evaluated. +matching_options.audiences == "all"+ requires every checked
|
|
654
769
|
# audience to match; otherwise any match suffices. Returns true/false or a
|
|
655
770
|
# propagated {RuleError} sentinel. Mirrors JS data-manager.ts:350-416.
|
|
656
|
-
def match_audiences(experience, visitor_properties, is_bucketed)
|
|
771
|
+
def match_audiences(experience, visitor_properties, is_bucketed, resolver)
|
|
657
772
|
# JS parity (data-manager.ts:356-416): +audiencesMatched+ defaults to FALSE
|
|
658
773
|
# and is only ever set true INSIDE +if (visitorProperties)+. A nil/absent
|
|
659
774
|
# visitor-properties bag therefore GATES the experience (false), even when
|
|
@@ -665,7 +780,7 @@ module ConvertSdk
|
|
|
665
780
|
to_check = audiences_to_check(experience, is_bucketed)
|
|
666
781
|
return true if to_check.empty? # unrestricted (no audiences, or all permanent+bucketed)
|
|
667
782
|
|
|
668
|
-
matched = matched_audiences(to_check, visitor_properties)
|
|
783
|
+
matched = matched_audiences(to_check, visitor_properties, resolver)
|
|
669
784
|
return matched if matched.is_a?(Sentinel)
|
|
670
785
|
|
|
671
786
|
audiences_verdict?(experience, matched, to_check)
|
|
@@ -690,12 +805,17 @@ module ConvertSdk
|
|
|
690
805
|
|
|
691
806
|
# Walk each checked audience's rules; collect the matches. A propagated
|
|
692
807
|
# {RuleError} sentinel from any walk short-circuits and is returned as-is.
|
|
693
|
-
|
|
808
|
+
# qs-04: +resolver+ is threaded unchanged into {RuleManager#is_rule_matched}
|
|
809
|
+
# — consulted ONLY by a +bucketed_into_experience_key+ leaf; every other
|
|
810
|
+
# leaf (the 3 generic rule types, AC7) ignores it, byte-identical to before.
|
|
811
|
+
def matched_audiences(to_check, visitor_properties, resolver)
|
|
694
812
|
matched = [] #: Array[Hash[String, untyped]]
|
|
695
813
|
to_check.each do |audience|
|
|
696
814
|
next unless audience["rules"]
|
|
697
815
|
|
|
698
|
-
result = @rule_manager&.is_rule_matched(
|
|
816
|
+
result = @rule_manager&.is_rule_matched(
|
|
817
|
+
visitor_properties, audience["rules"], "audience ##{audience["id"]}", resolver: resolver
|
|
818
|
+
)
|
|
699
819
|
return result if result.is_a?(Sentinel)
|
|
700
820
|
|
|
701
821
|
matched << audience if result == true
|
|
@@ -781,7 +901,17 @@ module ConvertSdk
|
|
|
781
901
|
# (atomic merge via DataStoreManager). Optionally also stores visitor
|
|
782
902
|
# properties as segments (JS updateVisitorProperties path). In-memory store
|
|
783
903
|
# ops only (NFR1; user-supplied Redis trades the no-disk contract).
|
|
904
|
+
#
|
|
905
|
+
# +attributes[:enable_storage]+ (qs-03 / RB-6 zero-trace) gates this write:
|
|
906
|
+
# a preview {Context} threads +enable_storage: false+ through
|
|
907
|
+
# {Context#decision_attributes} for the ENTIRE call (target AND other
|
|
908
|
+
# experiences), so a fresh decision made while previewing is never
|
|
909
|
+
# persisted. Absent (or any non-false value) defaults to +true+ — every
|
|
910
|
+
# existing non-preview caller (including direct {#get_bucketing} unit
|
|
911
|
+
# calls with a bare +{}+ attributes hash) is unaffected.
|
|
784
912
|
def persist_bucketing(visitor_id, experience_id, variation_id, attributes)
|
|
913
|
+
return unless attributes.fetch(:enable_storage, true)
|
|
914
|
+
|
|
785
915
|
manager = @data_store_manager
|
|
786
916
|
return if manager.nil?
|
|
787
917
|
|
|
@@ -840,9 +970,14 @@ module ConvertSdk
|
|
|
840
970
|
|
|
841
971
|
# Write the freshly-installed config through to the store, wrapped with a
|
|
842
972
|
# WALL-CLOCK +fetched_at+ for cross-process staleness. A no-op when no store
|
|
843
|
-
# or key is wired (standalone unit construction)
|
|
844
|
-
#
|
|
973
|
+
# or key is wired (standalone unit construction), or when
|
|
974
|
+
# +config_cache_disabled+ was set at construction (qs-03 — an active
|
|
975
|
+
# +debug_token+ suppresses the write so a shared store is never poisoned
|
|
976
|
+
# with a debug-widened config). The DataStoreManager contains any store
|
|
977
|
+
# failure (logged), so this never crashes an install.
|
|
845
978
|
def cache_config(frozen)
|
|
979
|
+
return if @config_cache_disabled
|
|
980
|
+
|
|
846
981
|
store = @data_store_manager
|
|
847
982
|
key = @config_key
|
|
848
983
|
return if store.nil? || key.nil?
|
|
@@ -887,6 +1022,14 @@ module ConvertSdk
|
|
|
887
1022
|
list.find { |entity| entity.is_a?(Hash) && entity["key"] == key }
|
|
888
1023
|
end
|
|
889
1024
|
|
|
1025
|
+
# Linear scan for the entity whose +"id"+ +to_s+-matches +id+. Mirrors
|
|
1026
|
+
# {#find_by_key}; +to_s+ comparison because ids may arrive as different
|
|
1027
|
+
# types across the wire (JSON Integer vs. a caller-supplied String).
|
|
1028
|
+
def find_by_id(list, entity_id)
|
|
1029
|
+
target = entity_id.to_s
|
|
1030
|
+
list.find { |entity| entity.is_a?(Hash) && entity["id"].to_s == target }
|
|
1031
|
+
end
|
|
1032
|
+
|
|
890
1033
|
# Build a recursively-frozen copy of +node+. Hashes and arrays are rebuilt
|
|
891
1034
|
# with frozen children then frozen; strings are duped-and-frozen; immutable
|
|
892
1035
|
# scalars (Integer/Float/Symbol/true/false/nil) pass through unchanged. The
|
data/lib/convert_sdk/redactor.rb
CHANGED
|
@@ -31,9 +31,11 @@ module ConvertSdk
|
|
|
31
31
|
# The single-character ellipsis appended after the unmasked prefix (or used
|
|
32
32
|
# as the whole replacement for short secrets).
|
|
33
33
|
MASK_GLYPH = "…"
|
|
34
|
-
# Matches an +http(s)+ URL
|
|
35
|
-
#
|
|
36
|
-
|
|
34
|
+
# Matches an +http(s)+ URL up to the next whitespace. +strip_url_queries+
|
|
35
|
+
# then drops the +?query+ portion in code, keeping the path. A single
|
|
36
|
+
# trailing quantifier (no quantifier competing after +https?://+) keeps the
|
|
37
|
+
# match linear — no polynomial backtracking on repeated +http://+ prefixes.
|
|
38
|
+
URL_QUERY_PATTERN = %r{https?://\S*}
|
|
37
39
|
|
|
38
40
|
# @param secrets [Array<String, nil>] secret values to mask. nil/blank
|
|
39
41
|
# entries are ignored.
|
|
@@ -87,7 +89,7 @@ module ConvertSdk
|
|
|
87
89
|
|
|
88
90
|
# Drop the query string from any URL in the message, keeping the path.
|
|
89
91
|
def strip_url_queries(message)
|
|
90
|
-
message.gsub(URL_QUERY_PATTERN,
|
|
92
|
+
message.gsub(URL_QUERY_PATTERN) { |url| url.split("?", 2).first }
|
|
91
93
|
end
|
|
92
94
|
end
|
|
93
95
|
end
|
|
@@ -65,9 +65,17 @@ module ConvertSdk
|
|
|
65
65
|
# @param data [Hash{String=>Object}] the key-value data to match.
|
|
66
66
|
# @param rule_set [Hash] the OR/AND/OR_WHEN rule structure.
|
|
67
67
|
# @param log_entry [String, nil] an optional label for the entity being matched.
|
|
68
|
+
# @param resolver [Proc, nil] qs-04 mutual-exclusion resolver:
|
|
69
|
+
# +->(target_experience_key) { true | false | nil }+. Threaded unchanged
|
|
70
|
+
# through the whole walk and consulted ONLY by a
|
|
71
|
+
# +bucketed_into_experience_key+ leaf ({#process_rule_item}); every other
|
|
72
|
+
# leaf ignores it. +nil+ (no resolver injected at all) makes such a leaf
|
|
73
|
+
# fall closed to +false+ with negation unapplied; a resolver that itself
|
|
74
|
+
# returns +nil+ for an unresolvable target key is treated as
|
|
75
|
+
# +bucketed_raw = false+ plus a warning naming the key.
|
|
68
76
|
# @return [Boolean, Sentinel] true/false, or a {RuleError} sentinel propagated
|
|
69
77
|
# from a leaf.
|
|
70
|
-
def is_rule_matched(data, rule_set, log_entry = nil)
|
|
78
|
+
def is_rule_matched(data, rule_set, log_entry = nil, resolver: nil)
|
|
71
79
|
or_groups = nonempty_block(rule_set, "OR")
|
|
72
80
|
unless or_groups
|
|
73
81
|
warn_rule_not_valid("RuleManager#is_rule_matched", log_entry)
|
|
@@ -76,7 +84,7 @@ module ConvertSdk
|
|
|
76
84
|
|
|
77
85
|
match = false
|
|
78
86
|
or_groups.each do |and_group|
|
|
79
|
-
match = process_and(data, and_group)
|
|
87
|
+
match = process_and(data, and_group, resolver)
|
|
80
88
|
return true if match == true
|
|
81
89
|
|
|
82
90
|
log_outcome("RuleManager#is_rule_matched", match, log_entry)
|
|
@@ -90,7 +98,7 @@ module ConvertSdk
|
|
|
90
98
|
|
|
91
99
|
# AND block: every OR_WHEN leaf-list must return true. Returns the first
|
|
92
100
|
# non-true result (false or a sentinel short-circuits). rule-manager.ts:191-220.
|
|
93
|
-
def process_and(data, and_group)
|
|
101
|
+
def process_and(data, and_group, resolver)
|
|
94
102
|
leaves = nonempty_block(and_group, "AND")
|
|
95
103
|
unless leaves
|
|
96
104
|
warn_rule_not_valid("RuleManager#process_and")
|
|
@@ -98,7 +106,7 @@ module ConvertSdk
|
|
|
98
106
|
end
|
|
99
107
|
|
|
100
108
|
leaves.each do |or_when|
|
|
101
|
-
match = process_or_when(data, or_when)
|
|
109
|
+
match = process_or_when(data, or_when, resolver)
|
|
102
110
|
return match if match != true
|
|
103
111
|
end
|
|
104
112
|
@log_manager&.debug("RuleManager#process_and: AND block matched")
|
|
@@ -107,7 +115,7 @@ module ConvertSdk
|
|
|
107
115
|
|
|
108
116
|
# OR_WHEN block: the first matching leaf wins. After the loop, returns the
|
|
109
117
|
# last result unless false (so a sentinel propagates). rule-manager.ts:229-255.
|
|
110
|
-
def process_or_when(data, or_when)
|
|
118
|
+
def process_or_when(data, or_when, resolver)
|
|
111
119
|
leaves = nonempty_block(or_when, "OR_WHEN")
|
|
112
120
|
unless leaves
|
|
113
121
|
warn_rule_not_valid("RuleManager#process_or_when")
|
|
@@ -116,7 +124,7 @@ module ConvertSdk
|
|
|
116
124
|
|
|
117
125
|
match = false
|
|
118
126
|
leaves.each do |rule|
|
|
119
|
-
match = process_rule_item(data, rule)
|
|
127
|
+
match = process_rule_item(data, rule, resolver)
|
|
120
128
|
return true if match == true
|
|
121
129
|
end
|
|
122
130
|
return match if match != false
|
|
@@ -124,11 +132,20 @@ module ConvertSdk
|
|
|
124
132
|
false
|
|
125
133
|
end
|
|
126
134
|
|
|
127
|
-
# A single leaf rule.
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
|
|
135
|
+
# A single leaf rule. A +bucketed_into_experience_key+ leaf (qs-04 mutual
|
|
136
|
+
# exclusion) is dispatched to {#process_mutual_exclusion_rule} BEFORE the
|
|
137
|
+
# generic shape validation / comparison-operator dispatch below — it never
|
|
138
|
+
# consults +match_type+ or +@comparisons+ (vestigial for this rule_type).
|
|
139
|
+
# Every other leaf takes the unchanged generic path: validates shape,
|
|
140
|
+
# resolves the operator from the comparison processor's dispatch map, and
|
|
141
|
+
# evaluates it against the matching data value — or against
|
|
142
|
+
# {Comparisons::UNDEFINED} for an absent key under an existence operator.
|
|
143
|
+
# rule-manager.ts:264-365.
|
|
144
|
+
def process_rule_item(data, rule, resolver)
|
|
145
|
+
if rule.is_a?(Hash) && rule["rule_type"] == "bucketed_into_experience_key"
|
|
146
|
+
return process_mutual_exclusion_rule(rule, resolver)
|
|
147
|
+
end
|
|
148
|
+
|
|
132
149
|
unless valid_rule?(rule)
|
|
133
150
|
warn_rule_not_valid("RuleManager#process_rule_item")
|
|
134
151
|
return false
|
|
@@ -147,6 +164,48 @@ module ConvertSdk
|
|
|
147
164
|
evaluate_leaf(data, rule, match_type, method, negation)
|
|
148
165
|
end
|
|
149
166
|
|
|
167
|
+
# The qs-04 mutual-exclusion leaf (+bucketed_into_experience_key+): resolves
|
|
168
|
+
# against the injected +resolver+ — a pure +->(target_experience_key) { true
|
|
169
|
+
# | false | nil }+ function of the target key — never against
|
|
170
|
+
# +@comparisons+; +match_type+ is vestigial and is never consulted for this
|
|
171
|
+
# rule_type.
|
|
172
|
+
#
|
|
173
|
+
# * No resolver threaded through the call at all (+resolver+ is +nil+):
|
|
174
|
+
# fail closed to +false+, negation UNAPPLIED (the capability is entirely
|
|
175
|
+
# absent, distinct from "resolver present but target unknown" below).
|
|
176
|
+
# * Resolver present, target key unresolvable (+resolver.call+ returns
|
|
177
|
+
# +nil+): warn naming the unresolved key, treat +bucketed_raw+ as +false+,
|
|
178
|
+
# THEN apply negation like any other known outcome.
|
|
179
|
+
# * Resolver present, target key resolved to +true+/+false+: that is
|
|
180
|
+
# +bucketed_raw+; negation applies on top of it as usual.
|
|
181
|
+
# * +rule["matching"]+ is malformed — a non-Hash, non-+nil+ value (e.g. a
|
|
182
|
+
# stray +match_type+ string) OR the +matching+ key missing entirely: this
|
|
183
|
+
# leaf skips +valid_rule?+ (unlike the generic path), so the malformed
|
|
184
|
+
# shape is guarded here directly. Fails closed to +false+, negation
|
|
185
|
+
# UNAPPLIED, and the resolver is NEVER invoked — mirrors the "no resolver
|
|
186
|
+
# injected" case's fail-closed spirit for a leaf that cannot be trusted.
|
|
187
|
+
def process_mutual_exclusion_rule(rule, resolver)
|
|
188
|
+
return false unless resolver
|
|
189
|
+
|
|
190
|
+
matching = rule["matching"]
|
|
191
|
+
return false unless matching.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
negated = matching["negated"] || false
|
|
194
|
+
target_key = rule["value"]
|
|
195
|
+
raw = resolver.call(target_key)
|
|
196
|
+
|
|
197
|
+
if raw.nil?
|
|
198
|
+
@log_manager&.warn(
|
|
199
|
+
"RuleManager#process_mutual_exclusion_rule: unresolved mutual-exclusion target #{target_key.inspect}"
|
|
200
|
+
)
|
|
201
|
+
bucketed_raw = false
|
|
202
|
+
else
|
|
203
|
+
bucketed_raw = raw == true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
negated ? !bucketed_raw : bucketed_raw
|
|
207
|
+
end
|
|
208
|
+
|
|
150
209
|
# Resolve the data value for the leaf's key and invoke the operator. Iterates
|
|
151
210
|
# the data keys honoring +keys_case_sensitive+; on a key match invokes the
|
|
152
211
|
# operator with the data value. On no key match, an existence operator is
|
|
@@ -44,6 +44,16 @@ module ConvertSdk
|
|
|
44
44
|
# (Story 2.1) into +StoreData["segments"]+. Stored data is string-keyed
|
|
45
45
|
# wire-world by design (so Epic 4's payload builder needs zero translation).
|
|
46
46
|
#
|
|
47
|
+
# == Zero-trace preview gate (qs-03 / RB-6)
|
|
48
|
+
#
|
|
49
|
+
# {#put_segments} and {#select_custom_segments} accept an +enable_storage:+
|
|
50
|
+
# keyword (default +true+) that suppresses ONLY the persistence write above —
|
|
51
|
+
# filtering / rule MATCHING is never affected. {Context#set_default_segments}
|
|
52
|
+
# and {Context#run_custom_segments} thread +!@preview+ through it so a
|
|
53
|
+
# preview-active context leaves zero trace in the store while still
|
|
54
|
+
# evaluating segment rules normally (mirrors JS SDK-6,
|
|
55
|
+
# +segments-manager.ts:74-174+).
|
|
56
|
+
#
|
|
47
57
|
# @api private
|
|
48
58
|
class SegmentsManager
|
|
49
59
|
# The +customSegments+ wire key — byte-identical to JS
|
|
@@ -89,12 +99,18 @@ module ConvertSdk
|
|
|
89
99
|
#
|
|
90
100
|
# @param visitor_id [String]
|
|
91
101
|
# @param segments [Hash] the candidate report-segments (string-keyed wire shape).
|
|
102
|
+
# @param enable_storage [Boolean] when +false+, suppress ONLY the persistence
|
|
103
|
+
# write below — the filtering above still runs identically. Threaded by
|
|
104
|
+
# {Context#set_default_segments} as +!@preview+ (qs-03 / RB-6 zero-trace);
|
|
105
|
+
# mirrors JS +SegmentsManager#putSegments+'s +enableStorage+ param
|
|
106
|
+
# (JS SDK-6, +segments-manager.ts:74-89+). Defaults to +true+ (every
|
|
107
|
+
# pre-qs-03 caller is unaffected).
|
|
92
108
|
# @return [void]
|
|
93
|
-
def put_segments(visitor_id, segments)
|
|
109
|
+
def put_segments(visitor_id, segments, enable_storage: true)
|
|
94
110
|
report_segments = filter_report_segments(segments)
|
|
95
111
|
return if report_segments.empty?
|
|
96
112
|
|
|
97
|
-
merge_segments(visitor_id, report_segments)
|
|
113
|
+
merge_segments(visitor_id, report_segments) if enable_storage
|
|
98
114
|
nil
|
|
99
115
|
end
|
|
100
116
|
|
|
@@ -113,11 +129,17 @@ module ConvertSdk
|
|
|
113
129
|
# @param segment_rule [Hash, nil] the visitor data the segment rules match
|
|
114
130
|
# against; +nil+ attaches every resolved segment unconditionally (JS:
|
|
115
131
|
# +if (!segmentRule || segmentsMatched)+).
|
|
132
|
+
# @param enable_storage [Boolean] when +false+, suppress ONLY the eventual
|
|
133
|
+
# {#put_segments} persistence write — rule MATCHING (and a propagated
|
|
134
|
+
# {RuleError}) is unaffected. Threaded by {Context#run_custom_segments} as
|
|
135
|
+
# +!@preview+ (qs-03 / RB-6 zero-trace); mirrors JS
|
|
136
|
+
# +SegmentsManager#selectCustomSegments+'s +enableStorage+ param
|
|
137
|
+
# (JS SDK-6, +segments-manager.ts:156-174+). Defaults to +true+.
|
|
116
138
|
# @return [Hash, Sentinel, nil] the updated segments hash, a propagated
|
|
117
139
|
# {RuleError}, or +nil+ when nothing matched.
|
|
118
|
-
def select_custom_segments(visitor_id, segment_keys, segment_rule = nil)
|
|
140
|
+
def select_custom_segments(visitor_id, segment_keys, segment_rule = nil, enable_storage: true)
|
|
119
141
|
segments = lookup_segments(segment_keys)
|
|
120
|
-
set_custom_segments(visitor_id, segments, segment_rule)
|
|
142
|
+
set_custom_segments(visitor_id, segments, segment_rule, enable_storage: enable_storage)
|
|
121
143
|
end
|
|
122
144
|
|
|
123
145
|
private
|
|
@@ -163,8 +185,9 @@ module ConvertSdk
|
|
|
163
185
|
# For each resolved segment: when a rule is supplied and nothing has matched
|
|
164
186
|
# yet, walk the segment's rules — a {RuleError} sentinel short-circuits and
|
|
165
187
|
# propagates out. On a match (or when no rule was supplied) append the segment
|
|
166
|
-
# id unless already stored. A non-empty append is persisted via {#put_segments}
|
|
167
|
-
|
|
188
|
+
# id unless already stored. A non-empty append is persisted via {#put_segments}
|
|
189
|
+
# (gated on +enable_storage+ — see {#select_custom_segments}).
|
|
190
|
+
def set_custom_segments(visitor_id, segments, segment_rule, enable_storage: true)
|
|
168
191
|
existing = stored_custom_segments(visitor_id)
|
|
169
192
|
segment_ids = [] #: Array[String]
|
|
170
193
|
matched = false #: (bool | Sentinel)
|
|
@@ -180,7 +203,7 @@ module ConvertSdk
|
|
|
180
203
|
append_segment_id(segment, existing, segment_ids)
|
|
181
204
|
end
|
|
182
205
|
|
|
183
|
-
persist_custom_segments(visitor_id, existing, segment_ids)
|
|
206
|
+
persist_custom_segments(visitor_id, existing, segment_ids, enable_storage: enable_storage)
|
|
184
207
|
end
|
|
185
208
|
|
|
186
209
|
# Append +segment["id"]+ (stringified) to the pending list unless it is already
|
|
@@ -199,15 +222,16 @@ module ConvertSdk
|
|
|
199
222
|
|
|
200
223
|
# Persist the newly-matched ids (appended to the existing list) into
|
|
201
224
|
# +StoreData["segments"]["customSegments"]+, or no-op + debug when nothing
|
|
202
|
-
# matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or
|
|
203
|
-
|
|
225
|
+
# matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or
|
|
226
|
+
# nil. +enable_storage+ passes straight through to {#put_segments}.
|
|
227
|
+
def persist_custom_segments(visitor_id, existing, segment_ids, enable_storage: true)
|
|
204
228
|
if segment_ids.empty?
|
|
205
229
|
@log_manager&.debug("SegmentsManager#set_custom_segments: no segments matched")
|
|
206
230
|
return nil
|
|
207
231
|
end
|
|
208
232
|
|
|
209
233
|
segments_data = { CUSTOM_SEGMENTS => existing + segment_ids }
|
|
210
|
-
put_segments(visitor_id, segments_data)
|
|
234
|
+
put_segments(visitor_id, segments_data, enable_storage: enable_storage)
|
|
211
235
|
segments_data
|
|
212
236
|
end
|
|
213
237
|
|
data/lib/convert_sdk/version.rb
CHANGED
data/lib/convert_sdk.rb
CHANGED
|
@@ -78,6 +78,42 @@ module ConvertSdk
|
|
|
78
78
|
@at_exit_registration_enabled = value
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Numeric-only segment pattern used to validate both the experience id and
|
|
82
|
+
# variation id halves of a preview param value (qs-03 AC9).
|
|
83
|
+
# @api private
|
|
84
|
+
PREVIEW_PARAM_NUMERIC_ONLY = /\A\d+\z/
|
|
85
|
+
|
|
86
|
+
# Parse a preview link param value of the form
|
|
87
|
+
# +"{experienceId}.{variationId}"+ (mirrors the web tracking script's force
|
|
88
|
+
# param +_conv_eforce={experienceId}.{variationId}+ and the JS SDK's
|
|
89
|
+
# +parsePreviewParam+ — javascript-sdk packages/js-sdk/src/parse-preview-param.ts).
|
|
90
|
+
#
|
|
91
|
+
# Splits on the FIRST dot only; both segments must be non-empty, numeric-only
|
|
92
|
+
# strings. Any other shape (missing dot, extra dot, empty segment,
|
|
93
|
+
# non-numeric segment, non-String input) returns +nil+.
|
|
94
|
+
#
|
|
95
|
+
# PURE: never raises, no side effects, no logging. Ruby-specific return
|
|
96
|
+
# contract (qs-03): a 2-element String array on success, not the JS oracle's
|
|
97
|
+
# +{experienceId, variationId}+ object shape.
|
|
98
|
+
#
|
|
99
|
+
# @param value [Object] expected to be a +String+; anything else returns +nil+.
|
|
100
|
+
# @return [Array(String, String), nil]
|
|
101
|
+
def self.parse_preview_param(value)
|
|
102
|
+
return nil unless value.is_a?(String)
|
|
103
|
+
|
|
104
|
+
dot_index = value.index(".")
|
|
105
|
+
return nil if dot_index.nil?
|
|
106
|
+
return nil if value.index(".", dot_index + 1)
|
|
107
|
+
|
|
108
|
+
experience_id = value[0...dot_index] #: String
|
|
109
|
+
variation_id = value[(dot_index + 1)..] #: String
|
|
110
|
+
|
|
111
|
+
return nil unless PREVIEW_PARAM_NUMERIC_ONLY.match?(experience_id)
|
|
112
|
+
return nil unless PREVIEW_PARAM_NUMERIC_ONLY.match?(variation_id)
|
|
113
|
+
|
|
114
|
+
[experience_id, variation_id]
|
|
115
|
+
end
|
|
116
|
+
|
|
81
117
|
# Build an SDK client from an SDK key (live config fetch) or a pre-fetched
|
|
82
118
|
# +data:+ object (direct data mode). THE public entry point.
|
|
83
119
|
#
|
|
@@ -199,6 +235,11 @@ module ConvertSdk
|
|
|
199
235
|
# DataManager is config-read-only). The account/project resolvers are left to
|
|
200
236
|
# the DataManager's own readers (its constructor defaults to +#account_id+ /
|
|
201
237
|
# +#project_id+) — the live config IS the source of those store-key halves here.
|
|
238
|
+
# +config_cache_disabled+ is derived from +!config.debug_token.nil?+ (qs-03,
|
|
239
|
+
# Ruby-SDK-only hardening — NOT a JS-parity concern): while a debug token is
|
|
240
|
+
# configured, the config-cache WRITE is suppressed so a shared store cannot
|
|
241
|
+
# be poisoned with a debug-widened config that a production reader might
|
|
242
|
+
# later pick up.
|
|
202
243
|
# @api private
|
|
203
244
|
def self.build_data_manager(config, log_manager, data_store_manager, clock,
|
|
204
245
|
bucketing_manager, rule_manager)
|
|
@@ -211,6 +252,7 @@ module ConvertSdk
|
|
|
211
252
|
ttl: config.data_refresh_interval,
|
|
212
253
|
bucketing_manager: bucketing_manager,
|
|
213
254
|
rule_manager: rule_manager,
|
|
255
|
+
config_cache_disabled: !config.debug_token.nil?,
|
|
214
256
|
**clock_option
|
|
215
257
|
)
|
|
216
258
|
end
|