convert_sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- metadata +93 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The audience/segmentation rule walk — OR -> AND -> OR_WHEN — ported EXACTLY
|
|
5
|
+
# from the JS SDK +packages/rules/src/rule-manager.ts+. JS is the only truth
|
|
6
|
+
# here; the PHP port is quarantined (it diverges on the existence operators and
|
|
7
|
+
# on +isIn+ case-folding — see {Comparisons}).
|
|
8
|
+
#
|
|
9
|
+
# Structure & short-circuit order (mirrors rule-manager.ts:117-255):
|
|
10
|
+
# * Top OR (+isRuleMatched+): for each AND-group, +match = process_and+;
|
|
11
|
+
# return +true+ on the first matching group; after the loop return +match+
|
|
12
|
+
# unless it is +false+ (so a {RuleError} sentinel propagates out). A missing
|
|
13
|
+
# or empty +OR+ logs +RULE_NOT_VALID+ at warn and returns +false+.
|
|
14
|
+
# * AND (+process_and+): for each OR_WHEN leaf-list, +match = process_or_when+;
|
|
15
|
+
# return +match+ on the FIRST non-+true+ result (a +false+ or a sentinel
|
|
16
|
+
# short-circuits the AND). All +true+ -> +true+. Missing/empty +AND+ ->
|
|
17
|
+
# +RULE_NOT_VALID+ warn + +false+.
|
|
18
|
+
# * OR_WHEN (+process_or_when+): for each leaf rule, +match = process_rule_item+;
|
|
19
|
+
# return +true+ on the first match; after the loop return +match+ unless it
|
|
20
|
+
# is +false+. Missing/empty +OR_WHEN+ -> +RULE_NOT_VALID+ warn + +false+.
|
|
21
|
+
#
|
|
22
|
+
# FR22 safety invariant: EVERY empty/missing block shape returns +false+ with a
|
|
23
|
+
# +RULE_NOT_VALID+ warn — an empty audience excludes everyone, it never matches
|
|
24
|
+
# everyone.
|
|
25
|
+
#
|
|
26
|
+
# The undefined-fallback (rule-manager.ts:323-333): when the data key for a leaf
|
|
27
|
+
# is ABSENT and the operator is +exists+/+doesNotExist+, the operator is invoked
|
|
28
|
+
# against {Comparisons::UNDEFINED} (JS +undefined+) so existence semantics hold
|
|
29
|
+
# for absent keys. Ruby models key-absence with the {Comparisons::UNDEFINED}
|
|
30
|
+
# marker because a plain +nil+ means "present null value" (JS +null+), which is
|
|
31
|
+
# a different existence outcome.
|
|
32
|
+
#
|
|
33
|
+
# RuleError propagation: a leaf that returns a {RuleError} sentinel propagates
|
|
34
|
+
# it up the walk via the non-true / non-false short-circuits above (Story 2.11
|
|
35
|
+
# maps the surfaced sentinel to the public return).
|
|
36
|
+
#
|
|
37
|
+
# Pure in-memory (NFR1). Rule options (+keys_case_sensitive+, +negation+) come
|
|
38
|
+
# from the injected {Config}, never inline. Valid-rule outcomes log at debug;
|
|
39
|
+
# invalid/empty shapes log +RULE_NOT_VALID+ at warn (rule-manager.ts levels).
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
class RuleManager
|
|
43
|
+
# The +RULE_NOT_VALID+ message — byte-identical to the JS SDK dictionary
|
|
44
|
+
# (+packages/enums/src/dictionary.ts:12+: "Provided rule is not valid"). Logged
|
|
45
|
+
# at warn for every empty/missing/invalid block, the FR22 exclusion signal.
|
|
46
|
+
RULE_NOT_VALID = "Provided rule is not valid"
|
|
47
|
+
|
|
48
|
+
# Build a rule walker bound to a {Config}'s rule options and a comparison
|
|
49
|
+
# processor.
|
|
50
|
+
#
|
|
51
|
+
# @param config [Config] supplies +keys_case_sensitive+ and +negation+.
|
|
52
|
+
# @param comparisons [#dispatch] the operator processor (defaults to
|
|
53
|
+
# {Comparisons}); must expose +dispatch+ (wire-name => method symbol) and
|
|
54
|
+
# respond to each mapped method as +(value, test_against, negation)+.
|
|
55
|
+
# @param log_manager [LogManager, nil] optional logger; warn for invalid
|
|
56
|
+
# shapes, debug for valid-rule outcomes.
|
|
57
|
+
def initialize(config:, comparisons: Comparisons, log_manager: nil)
|
|
58
|
+
@keys_case_sensitive = config.keys_case_sensitive
|
|
59
|
+
@comparisons = comparisons
|
|
60
|
+
@log_manager = log_manager
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Walk a rule set against a data hash. The top OR level.
|
|
64
|
+
#
|
|
65
|
+
# @param data [Hash{String=>Object}] the key-value data to match.
|
|
66
|
+
# @param rule_set [Hash] the OR/AND/OR_WHEN rule structure.
|
|
67
|
+
# @param log_entry [String, nil] an optional label for the entity being matched.
|
|
68
|
+
# @return [Boolean, Sentinel] true/false, or a {RuleError} sentinel propagated
|
|
69
|
+
# from a leaf.
|
|
70
|
+
def is_rule_matched(data, rule_set, log_entry = nil)
|
|
71
|
+
or_groups = nonempty_block(rule_set, "OR")
|
|
72
|
+
unless or_groups
|
|
73
|
+
warn_rule_not_valid("RuleManager#is_rule_matched", log_entry)
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
match = false
|
|
78
|
+
or_groups.each do |and_group|
|
|
79
|
+
match = process_and(data, and_group)
|
|
80
|
+
return true if match == true
|
|
81
|
+
|
|
82
|
+
log_outcome("RuleManager#is_rule_matched", match, log_entry)
|
|
83
|
+
end
|
|
84
|
+
return match if match != false
|
|
85
|
+
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# AND block: every OR_WHEN leaf-list must return true. Returns the first
|
|
92
|
+
# non-true result (false or a sentinel short-circuits). rule-manager.ts:191-220.
|
|
93
|
+
def process_and(data, and_group)
|
|
94
|
+
leaves = nonempty_block(and_group, "AND")
|
|
95
|
+
unless leaves
|
|
96
|
+
warn_rule_not_valid("RuleManager#process_and")
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
leaves.each do |or_when|
|
|
101
|
+
match = process_or_when(data, or_when)
|
|
102
|
+
return match if match != true
|
|
103
|
+
end
|
|
104
|
+
@log_manager&.debug("RuleManager#process_and: AND block matched")
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# OR_WHEN block: the first matching leaf wins. After the loop, returns the
|
|
109
|
+
# last result unless false (so a sentinel propagates). rule-manager.ts:229-255.
|
|
110
|
+
def process_or_when(data, or_when)
|
|
111
|
+
leaves = nonempty_block(or_when, "OR_WHEN")
|
|
112
|
+
unless leaves
|
|
113
|
+
warn_rule_not_valid("RuleManager#process_or_when")
|
|
114
|
+
return false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
match = false
|
|
118
|
+
leaves.each do |rule|
|
|
119
|
+
match = process_rule_item(data, rule)
|
|
120
|
+
return true if match == true
|
|
121
|
+
end
|
|
122
|
+
return match if match != false
|
|
123
|
+
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A single leaf rule. Validates shape, resolves the operator from the
|
|
128
|
+
# comparison processor's dispatch map, and evaluates it against the matching
|
|
129
|
+
# data value — or against {Comparisons::UNDEFINED} for an absent key under an
|
|
130
|
+
# existence operator. rule-manager.ts:264-365.
|
|
131
|
+
def process_rule_item(data, rule)
|
|
132
|
+
unless valid_rule?(rule)
|
|
133
|
+
warn_rule_not_valid("RuleManager#process_rule_item")
|
|
134
|
+
return false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
negation = rule["matching"]["negated"] || false
|
|
138
|
+
match_type = rule["matching"]["match_type"]
|
|
139
|
+
method = @comparisons.dispatch[match_type]
|
|
140
|
+
unless method
|
|
141
|
+
@log_manager&.warn(
|
|
142
|
+
"RuleManager#process_rule_item: rule matching type #{match_type.inspect} is not supported"
|
|
143
|
+
)
|
|
144
|
+
return false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
evaluate_leaf(data, rule, match_type, method, negation)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Resolve the data value for the leaf's key and invoke the operator. Iterates
|
|
151
|
+
# the data keys honoring +keys_case_sensitive+; on a key match invokes the
|
|
152
|
+
# operator with the data value. On no key match, an existence operator is
|
|
153
|
+
# invoked against the UNDEFINED marker (undefined-fallback). Otherwise false.
|
|
154
|
+
def evaluate_leaf(data, rule, match_type, method, negation)
|
|
155
|
+
data_value = lookup_data_value(data, rule["key"])
|
|
156
|
+
unless data_value.equal?(Comparisons::UNDEFINED)
|
|
157
|
+
result = @comparisons.public_send(method, data_value, rule["value"], negation)
|
|
158
|
+
debug_outcome("key matched", match_type, result)
|
|
159
|
+
return result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Key absent: only the existence operators fall back to the UNDEFINED
|
|
163
|
+
# marker (rule-manager.ts:323-333); everything else is a no-match.
|
|
164
|
+
if existence_operator?(match_type)
|
|
165
|
+
result = @comparisons.public_send(method, Comparisons::UNDEFINED, rule["value"], negation)
|
|
166
|
+
debug_outcome("existence-fallback", match_type, result)
|
|
167
|
+
return result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@log_manager&.debug("RuleManager#evaluate_leaf: key #{rule["key"].inspect} not found in data")
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Resolve the data value for a rule key honoring +keys_case_sensitive+.
|
|
175
|
+
# Returns {Comparisons::UNDEFINED} when the key is ABSENT — distinct from a
|
|
176
|
+
# present +nil+ value (JS null), which is returned as +nil+.
|
|
177
|
+
def lookup_data_value(data, rule_key)
|
|
178
|
+
return Comparisons::UNDEFINED unless data.is_a?(Hash) && !data.empty?
|
|
179
|
+
|
|
180
|
+
target = @keys_case_sensitive ? rule_key : rule_key.to_s.downcase
|
|
181
|
+
data.each do |key, value|
|
|
182
|
+
k = @keys_case_sensitive ? key : key.to_s.downcase
|
|
183
|
+
return value if k == target
|
|
184
|
+
end
|
|
185
|
+
Comparisons::UNDEFINED
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Debug-log a leaf evaluation outcome.
|
|
189
|
+
def debug_outcome(path, match_type, result)
|
|
190
|
+
@log_manager&.debug(
|
|
191
|
+
"RuleManager#evaluate_leaf: #{path} match_type=#{match_type.inspect} result=#{result.inspect}"
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# JS +isValidRule+ (rule-manager.ts:162-182): requires a +matching+ object
|
|
196
|
+
# with string +match_type+ and boolean +negated+; existence operators are
|
|
197
|
+
# valid without a +value+, all others require a +value+ field.
|
|
198
|
+
def valid_rule?(rule)
|
|
199
|
+
return false unless rule.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
matching = rule["matching"]
|
|
202
|
+
return false unless matching.is_a?(Hash)
|
|
203
|
+
return false unless matching["match_type"].is_a?(String)
|
|
204
|
+
return false unless [true, false].include?(matching["negated"])
|
|
205
|
+
return true if existence_operator?(matching["match_type"])
|
|
206
|
+
|
|
207
|
+
rule.key?("value")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The existence operators get the undefined-fallback and are value-optional.
|
|
211
|
+
# Mirrors the JS CookieMatchingOptions.EXISTS / DOES_NOT_EXIST special-case.
|
|
212
|
+
def existence_operator?(match_type)
|
|
213
|
+
%w[exists doesNotExist].include?(match_type)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Extract +container[key]+ as a non-empty Array, or +nil+ when the container
|
|
217
|
+
# is not a Hash, the key is missing, the value is not an Array, or the Array
|
|
218
|
+
# is empty. The single source of the FR22 "empty/missing block" predicate —
|
|
219
|
+
# every empty/missing OR/AND/OR_WHEN shape collapses to +nil+ here.
|
|
220
|
+
def nonempty_block(container, key)
|
|
221
|
+
return nil unless container.is_a?(Hash)
|
|
222
|
+
|
|
223
|
+
value = container[key]
|
|
224
|
+
return nil unless value.is_a?(Array) && !value.empty?
|
|
225
|
+
|
|
226
|
+
value
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Emit a RULE_NOT_VALID warn (FR22 exclusion signal) — rule-manager.ts warn
|
|
230
|
+
# sites at :148/:214/:249/:359.
|
|
231
|
+
def warn_rule_not_valid(site, log_entry = nil)
|
|
232
|
+
suffix = log_entry ? " (#{log_entry})" : ""
|
|
233
|
+
@log_manager&.warn("#{site}: #{RULE_NOT_VALID}#{suffix}")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Debug-log a valid-rule outcome (match / no-match / sentinel) at the OR level.
|
|
237
|
+
def log_outcome(site, match, log_entry)
|
|
238
|
+
suffix = log_entry ? " (#{log_entry})" : ""
|
|
239
|
+
@log_manager&.debug("#{site}: outcome=#{match.inspect}#{suffix}")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Visitor segmentation — the REPORTING-data layer that attaches segment ids to a
|
|
5
|
+
# visitor's +StoreData+ in the JS SDK's wire shape (FR28–FR30). Ported from the
|
|
6
|
+
# JS SDK +packages/segments/src/segments-manager.ts+; the PHP reference is
|
|
7
|
+
# QUARANTINED here because it diverges on two wire keys.
|
|
8
|
+
#
|
|
9
|
+
# == PHP-divergence #2 — the wire-key quarantine (FR30)
|
|
10
|
+
#
|
|
11
|
+
# JS +SegmentsKeys+ (+segments-keys.ts:7-15+) emit camelCase:
|
|
12
|
+
# +visitorType+ / +customSegments+. PHP +SegmentsKeys.php:14-15+ emit snake_case:
|
|
13
|
+
# +visitor_type+ / +custom_segments+ — a real, disk-verified divergence. Segment
|
|
14
|
+
# data rides the tracking payload's +segments+ object (Epic 4 +ApiManager+ emits
|
|
15
|
+
# the visitor's stored +segments+ verbatim), so the wrong key would silently
|
|
16
|
+
# mis-filter every Ruby segment-report. Ruby follows JS: the seven report-segment
|
|
17
|
+
# keys, and +customSegments+ in particular, are the ONLY ones persisted, as
|
|
18
|
+
# camelCase STRINGS at rest in +StoreData+. The {SegmentsManager} never produces
|
|
19
|
+
# the PHP variants; Story 3.2's quarantine spec asserts their absence.
|
|
20
|
+
#
|
|
21
|
+
# == The report-segment filter ({#filter_report_segments}, +data-manager.ts:1180-1199+)
|
|
22
|
+
#
|
|
23
|
+
# Exactly seven keys are report-segments: +country+, +browser+, +devices+,
|
|
24
|
+
# +source+, +campaign+, +visitorType+, +customSegments+. {#put_segments} keeps
|
|
25
|
+
# ONLY these; every other key is silently DROPPED (JS routes them to a separate
|
|
26
|
+
# +properties+ bucket the segments layer ignores) — IGNORE, not reject. Ruby adds
|
|
27
|
+
# a +debug+ line naming the dropped keys (an observability addition; JS drops
|
|
28
|
+
# silently). A filter that leaves NOTHING is a no-op write (JS +if (reportSegments)+).
|
|
29
|
+
#
|
|
30
|
+
# == Custom-segment evaluation REUSES the rule engine ({#select_custom_segments})
|
|
31
|
+
#
|
|
32
|
+
# +run_custom_segments+ does NOT introduce new rule logic. For each requested
|
|
33
|
+
# segment key it looks up the {ConfigSegment} entity (DataManager +segments+
|
|
34
|
+
# reader), evaluates that segment's +rules+ against the supplied
|
|
35
|
+
# +segment_rule+ data via {RuleManager#is_rule_matched} (so NEED_MORE_DATA and
|
|
36
|
+
# every operator semantic come for FREE), and — on a match — appends the
|
|
37
|
+
# segment's id to the visitor's stored +customSegments+ list (deduped). A
|
|
38
|
+
# surfaced {RuleError} sentinel propagates out verbatim (mirrors JS
|
|
39
|
+
# +setCustomSegments+'s +Object.values(RuleError).includes(...)+ early-return).
|
|
40
|
+
#
|
|
41
|
+
# == Persistence
|
|
42
|
+
#
|
|
43
|
+
# All writes flow through the {DataStoreManager} atomic visitor-data merge
|
|
44
|
+
# (Story 2.1) into +StoreData["segments"]+. Stored data is string-keyed
|
|
45
|
+
# wire-world by design (so Epic 4's payload builder needs zero translation).
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
class SegmentsManager
|
|
49
|
+
# The +customSegments+ wire key — byte-identical to JS
|
|
50
|
+
# +SegmentsKeys.CUSTOM_SEGMENTS+ (+segments-keys.ts:14+). The visitor's matched
|
|
51
|
+
# custom-segment ids live under this key in +StoreData["segments"]+.
|
|
52
|
+
CUSTOM_SEGMENTS = "customSegments"
|
|
53
|
+
|
|
54
|
+
# The full report-segment key set — byte-identical to JS +SegmentsKeys+
|
|
55
|
+
# (+segments-keys.ts:7-15+). The report-segment filter is restricted to exactly
|
|
56
|
+
# these seven; +visitorType+/+customSegments+ are the JS wire keys that diverge
|
|
57
|
+
# from PHP's snake_case variants (FR30). The SINGLE source of the allowed set.
|
|
58
|
+
SEGMENTS_KEYS = %w[
|
|
59
|
+
country browser devices source campaign visitorType customSegments
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# @param data_manager [DataManager] the config reader surface (supplies the
|
|
63
|
+
# {ConfigSegment} entities by key and the account/project store-key halves).
|
|
64
|
+
# @param data_store_manager [DataStoreManager] the persistence port (atomic
|
|
65
|
+
# visitor-data merge into +StoreData["segments"]+).
|
|
66
|
+
# @param account_resolver [#call] resolves the account id (store-key half).
|
|
67
|
+
# @param project_resolver [#call] resolves the project id (store-key half).
|
|
68
|
+
# @param rule_manager [RuleManager] the Epic 2 rule walker reused for
|
|
69
|
+
# custom-segment evaluation (never re-implemented here).
|
|
70
|
+
# @param log_manager [LogManager, nil] optional logger; debug on misses/drops,
|
|
71
|
+
# warn on already-present ids.
|
|
72
|
+
def initialize(data_manager:, data_store_manager:, account_resolver:,
|
|
73
|
+
project_resolver:, rule_manager:, log_manager: nil)
|
|
74
|
+
@data_manager = data_manager
|
|
75
|
+
@data_store_manager = data_store_manager
|
|
76
|
+
@account_resolver = account_resolver
|
|
77
|
+
@project_resolver = project_resolver
|
|
78
|
+
@rule_manager = rule_manager
|
|
79
|
+
@log_manager = log_manager
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Set default report-segments for a visitor (JS +setDefaultSegments+ ->
|
|
83
|
+
# +putSegments+, +context.ts:434-436+ / +segments-manager.ts:78-85+).
|
|
84
|
+
#
|
|
85
|
+
# The supplied segments are passed through {#filter_report_segments} (only the
|
|
86
|
+
# seven {SEGMENTS_KEYS} survive); a non-empty result is merged into the
|
|
87
|
+
# visitor's +StoreData["segments"]+ via the atomic store merge. An all-dropped
|
|
88
|
+
# input is a no-op (JS +if (reportSegments)+).
|
|
89
|
+
#
|
|
90
|
+
# @param visitor_id [String]
|
|
91
|
+
# @param segments [Hash] the candidate report-segments (string-keyed wire shape).
|
|
92
|
+
# @return [void]
|
|
93
|
+
def put_segments(visitor_id, segments)
|
|
94
|
+
report_segments = filter_report_segments(segments)
|
|
95
|
+
return if report_segments.empty?
|
|
96
|
+
|
|
97
|
+
merge_segments(visitor_id, report_segments)
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Evaluate the named custom segments for a visitor and attach matching ids
|
|
102
|
+
# (JS +selectCustomSegments+ -> +setCustomSegments+, +segments-manager.ts:153-185+).
|
|
103
|
+
#
|
|
104
|
+
# Each requested key is resolved to a {ConfigSegment} via the DataManager
|
|
105
|
+
# +segments+ reader; the segment's +rules+ are walked against +segment_rule+
|
|
106
|
+
# by {RuleManager#is_rule_matched}. A surfaced {RuleError} sentinel propagates
|
|
107
|
+
# out verbatim (no attachment). Matching segment ids are appended to the
|
|
108
|
+
# visitor's stored +customSegments+ list (deduped); an unknown key is skipped
|
|
109
|
+
# with a debug log.
|
|
110
|
+
#
|
|
111
|
+
# @param visitor_id [String]
|
|
112
|
+
# @param segment_keys [Array<String>] the segment keys to evaluate.
|
|
113
|
+
# @param segment_rule [Hash, nil] the visitor data the segment rules match
|
|
114
|
+
# against; +nil+ attaches every resolved segment unconditionally (JS:
|
|
115
|
+
# +if (!segmentRule || segmentsMatched)+).
|
|
116
|
+
# @return [Hash, Sentinel, nil] the updated segments hash, a propagated
|
|
117
|
+
# {RuleError}, or +nil+ when nothing matched.
|
|
118
|
+
def select_custom_segments(visitor_id, segment_keys, segment_rule = nil)
|
|
119
|
+
segments = lookup_segments(segment_keys)
|
|
120
|
+
set_custom_segments(visitor_id, segments, segment_rule)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Keep ONLY the seven {SEGMENTS_KEYS} report-segments; silently drop the rest
|
|
126
|
+
# (JS +filterReportSegments+, +data-manager.ts:1180-1199+ — non-segment keys go
|
|
127
|
+
# to a +properties+ bucket the segments layer ignores). Dropped keys are named
|
|
128
|
+
# in a debug line (Ruby observability addition). Returns a string-keyed hash
|
|
129
|
+
# (possibly empty).
|
|
130
|
+
def filter_report_segments(segments)
|
|
131
|
+
return {} unless segments.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
kept = {} #: Hash[String, untyped]
|
|
134
|
+
dropped = [] #: Array[String]
|
|
135
|
+
segments.each do |key, value|
|
|
136
|
+
if SEGMENTS_KEYS.include?(key)
|
|
137
|
+
kept[key] = value
|
|
138
|
+
else
|
|
139
|
+
dropped << key
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
unless dropped.empty?
|
|
143
|
+
@log_manager&.debug("SegmentsManager#filter_report_segments: dropped non-report keys #{dropped.inspect}")
|
|
144
|
+
end
|
|
145
|
+
kept
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Resolve the requested segment keys to {ConfigSegment} entities via the
|
|
149
|
+
# DataManager +segments+ reader (JS +getEntities(keys, 'segments')+). Unknown
|
|
150
|
+
# keys yield no entity and are debug-logged + skipped. Preserves request order.
|
|
151
|
+
def lookup_segments(segment_keys)
|
|
152
|
+
return [] unless segment_keys.is_a?(Array)
|
|
153
|
+
|
|
154
|
+
all = @data_manager.segments
|
|
155
|
+
segment_keys.filter_map do |key|
|
|
156
|
+
entity = all.find { |seg| seg.is_a?(Hash) && seg["key"] == key }
|
|
157
|
+
@log_manager&.debug("SegmentsManager#lookup_segments: no segment found for key=#{key}") unless entity
|
|
158
|
+
entity
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The id-attachment core (JS +setCustomSegments+, +segments-manager.ts:87-143+).
|
|
163
|
+
# For each resolved segment: when a rule is supplied and nothing has matched
|
|
164
|
+
# yet, walk the segment's rules — a {RuleError} sentinel short-circuits and
|
|
165
|
+
# 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
|
+
def set_custom_segments(visitor_id, segments, segment_rule)
|
|
168
|
+
existing = stored_custom_segments(visitor_id)
|
|
169
|
+
segment_ids = [] #: Array[String]
|
|
170
|
+
matched = false #: (bool | Sentinel)
|
|
171
|
+
|
|
172
|
+
segments.each do |segment|
|
|
173
|
+
if segment_rule && matched != true
|
|
174
|
+
matched = @rule_manager.is_rule_matched(segment_rule, segment["rules"], "ConfigSegment ##{segment["id"]}")
|
|
175
|
+
return matched if matched.is_a?(Sentinel)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
next unless !segment_rule || matched == true
|
|
179
|
+
|
|
180
|
+
append_segment_id(segment, existing, segment_ids)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
persist_custom_segments(visitor_id, existing, segment_ids)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Append +segment["id"]+ (stringified) to the pending list unless it is already
|
|
187
|
+
# stored or already pending; an already-stored id warns (JS
|
|
188
|
+
# +CUSTOM_SEGMENTS_KEY_FOUND+).
|
|
189
|
+
def append_segment_id(segment, existing, segment_ids)
|
|
190
|
+
id = segment["id"]&.to_s
|
|
191
|
+
return if id.nil?
|
|
192
|
+
|
|
193
|
+
if existing.include?(id)
|
|
194
|
+
@log_manager&.warn("SegmentsManager#set_custom_segments: custom segment id #{id} already stored")
|
|
195
|
+
elsif !segment_ids.include?(id)
|
|
196
|
+
segment_ids << id
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Persist the newly-matched ids (appended to the existing list) into
|
|
201
|
+
# +StoreData["segments"]["customSegments"]+, or no-op + debug when nothing
|
|
202
|
+
# matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or nil.
|
|
203
|
+
def persist_custom_segments(visitor_id, existing, segment_ids)
|
|
204
|
+
if segment_ids.empty?
|
|
205
|
+
@log_manager&.debug("SegmentsManager#set_custom_segments: no segments matched")
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
segments_data = { CUSTOM_SEGMENTS => existing + segment_ids }
|
|
210
|
+
put_segments(visitor_id, segments_data)
|
|
211
|
+
segments_data
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The visitor's currently-stored +customSegments+ id list (or +[]+), read from
|
|
215
|
+
# +StoreData["segments"]+ through the store seam.
|
|
216
|
+
def stored_custom_segments(visitor_id)
|
|
217
|
+
key = @data_store_manager.visitor_key(account_id, project_id, visitor_id)
|
|
218
|
+
stored = @data_store_manager.get(key)
|
|
219
|
+
segments = stored.is_a?(Hash) ? stored["segments"] : nil
|
|
220
|
+
list = segments.is_a?(Hash) ? segments[CUSTOM_SEGMENTS] : nil
|
|
221
|
+
list.is_a?(Array) ? list : []
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Atomically merge a report-segments partial into +StoreData["segments"]+.
|
|
225
|
+
def merge_segments(visitor_id, report_segments)
|
|
226
|
+
@data_store_manager.merge_visitor_data(account_id, project_id, visitor_id) do |_current|
|
|
227
|
+
{ "segments" => report_segments }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The account / project halves of the visitor store key (coerced to String —
|
|
232
|
+
# nil-safe before any config is installed).
|
|
233
|
+
def account_id
|
|
234
|
+
@account_resolver.call.to_s
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def project_id
|
|
238
|
+
@project_resolver.call.to_s
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# A frozen singleton sentinel — the SDK's public contract for a *business miss*.
|
|
5
|
+
#
|
|
6
|
+
# Bucketing and feature decisions that find no answer (no data, not enough
|
|
7
|
+
# data, no variation decided) return a +Sentinel+ singleton rather than raising
|
|
8
|
+
# or returning a bare +nil+. This is architecture Decision 2: misses are
|
|
9
|
+
# signaled by value, never by exception.
|
|
10
|
+
#
|
|
11
|
+
# The protocol a +Sentinel+ implements:
|
|
12
|
+
#
|
|
13
|
+
# * +#to_s+ — the byte-identical JS wire string (appears in payloads/logs).
|
|
14
|
+
# * +#key+ — always +nil+, so the documented integrator pattern
|
|
15
|
+
# +case variation&.key+ falls through to the +else+ branch on a miss.
|
|
16
|
+
# * +#error?+ — always +true+, the value-object discriminator that distinguishes
|
|
17
|
+
# a sentinel from a real {BucketedVariation}/{BucketedFeature} (both +false+).
|
|
18
|
+
#
|
|
19
|
+
# Sentinels are exposed as frozen singleton constants (e.g.
|
|
20
|
+
# {RuleError::NO_DATA_FOUND}), so callers can branch on object identity for
|
|
21
|
+
# granular handling:
|
|
22
|
+
#
|
|
23
|
+
# result = context.run_experience("homepage-test")
|
|
24
|
+
# case result.key
|
|
25
|
+
# when nil
|
|
26
|
+
# # a miss — inspect which one via identity
|
|
27
|
+
# show_default if result.equal?(ConvertSdk::RuleError::NO_DATA_FOUND)
|
|
28
|
+
# else
|
|
29
|
+
# render_variation(result.key)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Equality is intentionally left as default object identity (no +==+ override):
|
|
33
|
+
# two distinct +Sentinel+ instances built from the same wire string are NOT
|
|
34
|
+
# equal, which is why the canonical instances live as shared frozen constants.
|
|
35
|
+
class Sentinel
|
|
36
|
+
# @param wire_string [String] the JS-parity wire string this sentinel emits.
|
|
37
|
+
def initialize(wire_string)
|
|
38
|
+
@wire_string = wire_string.dup.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] the byte-identical JS wire string.
|
|
43
|
+
def to_s
|
|
44
|
+
@wire_string
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [nil] always nil, so +case variation&.key+ falls through to else.
|
|
48
|
+
def key
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] always true — a sentinel always signals a business miss.
|
|
53
|
+
def error?
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Stores backing the SDK's persistence port (sticky bucketing, goal dedup,
|
|
5
|
+
# config caching). Every store duck-types to +#get(key)+ / +#set(key, value)+
|
|
6
|
+
# — the public extension point validated by {DataStoreManager} at wiring time.
|
|
7
|
+
module Stores
|
|
8
|
+
# The default in-process store: a plain +Hash+ guarded by a +Mutex+.
|
|
9
|
+
#
|
|
10
|
+
# +MemoryStore+ is what {DataStoreManager} falls back to when no custom
|
|
11
|
+
# store is supplied (or a supplied store fails validation). It satisfies the
|
|
12
|
+
# duck-typed store contract — +#get+ / +#set+ — with both operations
|
|
13
|
+
# serialized through a single mutex so concurrent reads and writes from
|
|
14
|
+
# multiple threads cannot corrupt the underlying +Hash+ or lose a write.
|
|
15
|
+
#
|
|
16
|
+
# == Per-process limitation
|
|
17
|
+
#
|
|
18
|
+
# State lives only in this process's memory. It is NOT shared across
|
|
19
|
+
# processes, workers, or machines: sticky bucketing (Story 2.11) and goal
|
|
20
|
+
# deduplication (Story 4.3) that round-trip through a +MemoryStore+ are
|
|
21
|
+
# therefore consistent only within the lifetime of a single process. A
|
|
22
|
+
# forked web worker, a restarted process, or a second host each start with
|
|
23
|
+
# an empty store. For cross-process stickiness and dedup, supply a shared
|
|
24
|
+
# backing store — +RedisStore+ (Story 2.2) is the first-party option.
|
|
25
|
+
#
|
|
26
|
+
# == Thread safety
|
|
27
|
+
#
|
|
28
|
+
# All access to the backing +Hash+ is serialized by +@mutex+; there is no
|
|
29
|
+
# public path to the +Hash+ that bypasses the lock.
|
|
30
|
+
class MemoryStore
|
|
31
|
+
def initialize
|
|
32
|
+
# Thread safety: guarded by @mutex.
|
|
33
|
+
@data = {}
|
|
34
|
+
@mutex = Thread::Mutex.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Read the value stored under +key+.
|
|
38
|
+
#
|
|
39
|
+
# @param key [String] the lookup key.
|
|
40
|
+
# @return [Object, nil] the stored value, or +nil+ if the key is absent.
|
|
41
|
+
def get(key)
|
|
42
|
+
@mutex.synchronize { @data[key] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Store +value+ under +key+, overwriting any existing value.
|
|
46
|
+
#
|
|
47
|
+
# @param key [String] the storage key.
|
|
48
|
+
# @param value [Object] the value to store.
|
|
49
|
+
# @return [Object] the stored value.
|
|
50
|
+
def set(key, value)
|
|
51
|
+
@mutex.synchronize { @data[key] = value }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|