convert_sdk 1.0.0 → 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/README.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 +193 -38
- 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
|
@@ -18,19 +18,21 @@ module ConvertSdk
|
|
|
18
18
|
#
|
|
19
19
|
# == No raw config hash crosses the boundary
|
|
20
20
|
#
|
|
21
|
-
# The parsed config
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
21
|
+
# The parsed config snapshot is exposed ONLY through hand-written reader methods
|
|
22
|
+
# (+#experiences+, +#feature_by_key(key)+, …) that return frozen sub-hashes /
|
|
23
|
+
# arrays. There is no public accessor for the raw snapshot and no OpenAPI codegen
|
|
24
|
+
# — the reader inventory is derived by hand from the actual config wire shape
|
|
25
|
+
# (the vendored +test-config.json+ fixture).
|
|
26
26
|
#
|
|
27
|
-
# == Wire shape
|
|
27
|
+
# == Wire shape (flat/root — JS/PHP/Android parity)
|
|
28
28
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# +"
|
|
33
|
-
#
|
|
29
|
+
# Entities live at the ROOT of the config snapshot: +account_id+, +project+,
|
|
30
|
+
# +experiences+, +features+, +goals+, +audiences+, +segments+,
|
|
31
|
+
# +archived_experiences+, and optional +locations+. +#project_id+ is
|
|
32
|
+
# +root["project"]["id"]+. This matches the live endpoint shape (no +"data"+
|
|
33
|
+
# wrapper), the JS SDK, the PHP SDK, and the Android OpenAPI-generated schema.
|
|
34
|
+
# Readers tolerate sparse or absent keys (return +nil+ / +[]+) so a partial
|
|
35
|
+
# config never crashes a reader.
|
|
34
36
|
#
|
|
35
37
|
# == Degrade-gracefully (NFR12)
|
|
36
38
|
#
|
|
@@ -90,23 +92,27 @@ module ConvertSdk
|
|
|
90
92
|
# so a Context can supply its own resolution without re-reading config.
|
|
91
93
|
# @param project_resolver [#call, nil] returns the project id for the visitor
|
|
92
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.
|
|
93
102
|
def initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil,
|
|
94
103
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil,
|
|
95
104
|
bucketing_manager: nil, rule_manager: nil,
|
|
96
|
-
account_resolver: nil, project_resolver: nil)
|
|
105
|
+
account_resolver: nil, project_resolver: nil, config_cache_disabled: false)
|
|
97
106
|
@log_manager = log_manager
|
|
98
107
|
@data_store_manager = data_store_manager
|
|
99
108
|
@config_key = config_key
|
|
100
109
|
@ttl = ttl
|
|
110
|
+
@config_cache_disabled = config_cache_disabled
|
|
101
111
|
# Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured".
|
|
102
112
|
@timer_off = ttl.nil?
|
|
103
113
|
@clock = clock
|
|
104
114
|
@refetch = refetch
|
|
105
|
-
|
|
106
|
-
@bucketing_manager = bucketing_manager
|
|
107
|
-
@rule_manager = rule_manager
|
|
108
|
-
@account_resolver = account_resolver || -> { account_id }
|
|
109
|
-
@project_resolver = project_resolver || -> { project_id }
|
|
115
|
+
assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver)
|
|
110
116
|
# The deep-frozen config envelope, or nil before the first install. Read
|
|
111
117
|
# lock-free by every reader; replaced atomically under @config_mutex.
|
|
112
118
|
@config = nil #: Hash[String, untyped]?
|
|
@@ -138,8 +144,8 @@ module ConvertSdk
|
|
|
138
144
|
# marker, so exactly one install in the manager's lifetime is +:first+ even
|
|
139
145
|
# under concurrent installs.
|
|
140
146
|
#
|
|
141
|
-
# @param hash [Hash{String=>Object}] the parsed config
|
|
142
|
-
# (
|
|
147
|
+
# @param hash [Hash{String=>Object}] the parsed flat config snapshot
|
|
148
|
+
# (entities at the root — +account_id+, +project+, +experiences+, etc.).
|
|
143
149
|
# @return [Symbol, false] +:first+ on the first successful install,
|
|
144
150
|
# +:updated+ on any subsequent install, or +false+ when the argument was
|
|
145
151
|
# rejected (non-Hash) and no swap happened.
|
|
@@ -223,22 +229,31 @@ module ConvertSdk
|
|
|
223
229
|
(@clock.call - fetched_at) > effective_ttl
|
|
224
230
|
end
|
|
225
231
|
|
|
226
|
-
# @return [Boolean] true once a config
|
|
232
|
+
# @return [Boolean] true once a USABLE config (account_id + project.id both
|
|
233
|
+
# present) is installed. Mirrors JS SDK's +isValidConfigData+ check
|
|
234
|
+
# (+account_id && project.id+). A snapshot that carries a +project+ key but
|
|
235
|
+
# no id (e.g. +project: {}+) or a malformed +project+ value is NOT considered
|
|
236
|
+
# available — avoids silently serving nil reads to decision paths.
|
|
227
237
|
def config_available?
|
|
228
|
-
|
|
238
|
+
!account_id.nil? && !project_id.nil?
|
|
229
239
|
end
|
|
230
240
|
|
|
231
|
-
# @return [String, nil] the account id (+
|
|
241
|
+
# @return [String, nil] the account id (+account_id+ at the config root), or nil pre-config.
|
|
232
242
|
def account_id
|
|
233
243
|
data&.fetch("account_id", nil)
|
|
234
244
|
end
|
|
235
245
|
|
|
236
|
-
# @return [String, nil] the project id (+
|
|
246
|
+
# @return [String, nil] the project id (+project.id+ at the config root), or nil pre-config.
|
|
247
|
+
# Type-safe against a non-Hash +project+ value (e.g. a String in a malformed
|
|
248
|
+
# direct-data config): +&.+ guards nil only; a non-nil non-Hash would raise
|
|
249
|
+
# NoMethodError on +#fetch+. Mirrors JS optional-chaining safety
|
|
250
|
+
# (+data?.project?.id+).
|
|
237
251
|
def project_id
|
|
238
|
-
project
|
|
252
|
+
p = project
|
|
253
|
+
p.is_a?(Hash) ? p.fetch("id", nil) : nil
|
|
239
254
|
end
|
|
240
255
|
|
|
241
|
-
# @return [Hash, nil] the frozen +
|
|
256
|
+
# @return [Hash, nil] the frozen +project+ sub-hash at the config root, or nil pre-config.
|
|
242
257
|
def project
|
|
243
258
|
data&.fetch("project", nil)
|
|
244
259
|
end
|
|
@@ -280,6 +295,17 @@ module ConvertSdk
|
|
|
280
295
|
find_by_key(experiences, key)
|
|
281
296
|
end
|
|
282
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
|
+
|
|
283
309
|
# @param key [String] the feature +key+ to find.
|
|
284
310
|
# @return [Hash, nil] the frozen feature with that key, or nil.
|
|
285
311
|
def feature_by_key(key)
|
|
@@ -327,6 +353,32 @@ module ConvertSdk
|
|
|
327
353
|
retrieve_bucketing(visitor_id, experience, attributes)
|
|
328
354
|
end
|
|
329
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
|
+
|
|
330
382
|
# ========================== CONVERSION TRACKING =========================
|
|
331
383
|
# Track a conversion for +visitor_id+ on +goal_key+ with optional revenue /
|
|
332
384
|
# transaction +goal_data+, applying two-level goal dedup (Story 4.3).
|
|
@@ -382,6 +434,17 @@ module ConvertSdk
|
|
|
382
434
|
|
|
383
435
|
private
|
|
384
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
|
+
|
|
385
448
|
# The atomic check-then-mark (Story 4.3 / qs-01). Runs the dedup decision AND
|
|
386
449
|
# the mark inside ONE store-merge block (the merge mutex). Returns true when
|
|
387
450
|
# the caller should enqueue, false when the conversion is deduplicated.
|
|
@@ -465,6 +528,35 @@ module ConvertSdk
|
|
|
465
528
|
bucketing.is_a?(Hash) ? bucketing : {}
|
|
466
529
|
end
|
|
467
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
|
+
|
|
468
560
|
# Steps 1-7: resolve the experience and run every eligibility gate up to (but
|
|
469
561
|
# not including) traffic allocation. Returns the matched experience Hash, a
|
|
470
562
|
# {Sentinel} (a propagated {RuleError} from a rule walk), or +nil+ (a plain
|
|
@@ -484,7 +576,11 @@ module ConvertSdk
|
|
|
484
576
|
return reason_miss(experience, "location not match") unless location_outcome
|
|
485
577
|
|
|
486
578
|
# Step 6 — audiences (permanent skipped when bucketed; transient always).
|
|
487
|
-
|
|
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)
|
|
488
584
|
return audiences_outcome if audiences_outcome.is_a?(Sentinel)
|
|
489
585
|
|
|
490
586
|
# Step 7 — custom segments. Both must pass to reach variation selection.
|
|
@@ -555,8 +651,7 @@ module ConvertSdk
|
|
|
555
651
|
# No covering bucket OR a drifted-out selected id -> VARIATION_NOT_DECIDED.
|
|
556
652
|
def bucket_fresh(visitor_id, experience, attributes)
|
|
557
653
|
experience_id = experience["id"].to_s
|
|
558
|
-
|
|
559
|
-
decision = @bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
|
|
654
|
+
decision = fresh_bucketing_decision(visitor_id, experience, experience_id)
|
|
560
655
|
variation_id = decision&.fetch(:variation_id, nil)
|
|
561
656
|
variation = variation_id && retrieve_variation(experience, variation_id)
|
|
562
657
|
if variation.nil?
|
|
@@ -569,6 +664,37 @@ module ConvertSdk
|
|
|
569
664
|
build_bucketed_variation(experience, variation, decision&.fetch(:bucketing_allocation, nil))
|
|
570
665
|
end
|
|
571
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
|
+
|
|
572
698
|
# True when +experience.id+ is in the archived-experiences list (to_s match).
|
|
573
699
|
def archived?(experience)
|
|
574
700
|
id = experience["id"].to_s
|
|
@@ -642,7 +768,7 @@ module ConvertSdk
|
|
|
642
768
|
# re-evaluated. +matching_options.audiences == "all"+ requires every checked
|
|
643
769
|
# audience to match; otherwise any match suffices. Returns true/false or a
|
|
644
770
|
# propagated {RuleError} sentinel. Mirrors JS data-manager.ts:350-416.
|
|
645
|
-
def match_audiences(experience, visitor_properties, is_bucketed)
|
|
771
|
+
def match_audiences(experience, visitor_properties, is_bucketed, resolver)
|
|
646
772
|
# JS parity (data-manager.ts:356-416): +audiencesMatched+ defaults to FALSE
|
|
647
773
|
# and is only ever set true INSIDE +if (visitorProperties)+. A nil/absent
|
|
648
774
|
# visitor-properties bag therefore GATES the experience (false), even when
|
|
@@ -654,7 +780,7 @@ module ConvertSdk
|
|
|
654
780
|
to_check = audiences_to_check(experience, is_bucketed)
|
|
655
781
|
return true if to_check.empty? # unrestricted (no audiences, or all permanent+bucketed)
|
|
656
782
|
|
|
657
|
-
matched = matched_audiences(to_check, visitor_properties)
|
|
783
|
+
matched = matched_audiences(to_check, visitor_properties, resolver)
|
|
658
784
|
return matched if matched.is_a?(Sentinel)
|
|
659
785
|
|
|
660
786
|
audiences_verdict?(experience, matched, to_check)
|
|
@@ -679,12 +805,17 @@ module ConvertSdk
|
|
|
679
805
|
|
|
680
806
|
# Walk each checked audience's rules; collect the matches. A propagated
|
|
681
807
|
# {RuleError} sentinel from any walk short-circuits and is returned as-is.
|
|
682
|
-
|
|
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)
|
|
683
812
|
matched = [] #: Array[Hash[String, untyped]]
|
|
684
813
|
to_check.each do |audience|
|
|
685
814
|
next unless audience["rules"]
|
|
686
815
|
|
|
687
|
-
result = @rule_manager&.is_rule_matched(
|
|
816
|
+
result = @rule_manager&.is_rule_matched(
|
|
817
|
+
visitor_properties, audience["rules"], "audience ##{audience["id"]}", resolver: resolver
|
|
818
|
+
)
|
|
688
819
|
return result if result.is_a?(Sentinel)
|
|
689
820
|
|
|
690
821
|
matched << audience if result == true
|
|
@@ -770,7 +901,17 @@ module ConvertSdk
|
|
|
770
901
|
# (atomic merge via DataStoreManager). Optionally also stores visitor
|
|
771
902
|
# properties as segments (JS updateVisitorProperties path). In-memory store
|
|
772
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.
|
|
773
912
|
def persist_bucketing(visitor_id, experience_id, variation_id, attributes)
|
|
913
|
+
return unless attributes.fetch(:enable_storage, true)
|
|
914
|
+
|
|
774
915
|
manager = @data_store_manager
|
|
775
916
|
return if manager.nil?
|
|
776
917
|
|
|
@@ -829,9 +970,14 @@ module ConvertSdk
|
|
|
829
970
|
|
|
830
971
|
# Write the freshly-installed config through to the store, wrapped with a
|
|
831
972
|
# WALL-CLOCK +fetched_at+ for cross-process staleness. A no-op when no store
|
|
832
|
-
# or key is wired (standalone unit construction)
|
|
833
|
-
#
|
|
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.
|
|
834
978
|
def cache_config(frozen)
|
|
979
|
+
return if @config_cache_disabled
|
|
980
|
+
|
|
835
981
|
store = @data_store_manager
|
|
836
982
|
key = @config_key
|
|
837
983
|
return if store.nil? || key.nil?
|
|
@@ -856,13 +1002,14 @@ module ConvertSdk
|
|
|
856
1002
|
@ttl || ConvertSdk::DEFAULT_CONFIG_TTL
|
|
857
1003
|
end
|
|
858
1004
|
|
|
859
|
-
# The
|
|
860
|
-
# Read lock-free: @config is either nil or a fully-frozen
|
|
1005
|
+
# The live snapshot root (entities live at the root — JS/PHP/Android parity),
|
|
1006
|
+
# or nil pre-config. Read lock-free: @config is either nil or a fully-frozen
|
|
1007
|
+
# graph.
|
|
861
1008
|
def data
|
|
862
|
-
@config
|
|
1009
|
+
@config
|
|
863
1010
|
end
|
|
864
1011
|
|
|
865
|
-
# Fetch a frozen collection
|
|
1012
|
+
# Fetch a frozen collection at the config root, defaulting to a frozen empty
|
|
866
1013
|
# array when the snapshot or the key is absent.
|
|
867
1014
|
def collection(name)
|
|
868
1015
|
found = data&.fetch(name, nil)
|
|
@@ -875,6 +1022,14 @@ module ConvertSdk
|
|
|
875
1022
|
list.find { |entity| entity.is_a?(Hash) && entity["key"] == key }
|
|
876
1023
|
end
|
|
877
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
|
+
|
|
878
1033
|
# Build a recursively-frozen copy of +node+. Hashes and arrays are rebuilt
|
|
879
1034
|
# with frozen children then frozen; strings are duped-and-frozen; immutable
|
|
880
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