Class: ConvertSdk::Context
- Inherits:
-
Object
- Object
- ConvertSdk::Context
- Defined in:
- lib/convert_sdk/context.rb
Overview
The per-visitor public surface — THE object an integrator holds for the lifetime of one web request or background job.
A Context is created by ConvertSdk::Client#create_context and binds together one
visitor (its id + normalised attributes) and the SDK's shared, injected
managers (config, store, events, logging). It is deliberately a stable
shell: the decisioning methods (+run_experience(s)+, run_feature(s),
run_custom_segments, track_conversion) attach to this class in later
stories — this story builds creation, attribute normalisation, property
updates, and the two config/visitor-data lookups.
Deep-stringify at the public boundary (FR11)
Ruby integrators write symbol keys (+{ country: "US" }+); Rails params arrive string-keyed (+{ "country" => "US" }+). Both must behave identically, so EVERY attribute hash crossing the public boundary (the constructor and #update_visitor_properties) is recursively deep-stringified ONCE, here — symbol keys become strings through nested hashes and arrays-of-hashes. The internals (and everything written to the store, which is wire-world) then operate EXCLUSIVELY on string keys. Values are never coerced — only keys. (This normalisation has no JS parallel; JS has no symbol-as-hash-key idiom.)
Independence (FR12)
Each ConvertSdk::Client#create_context call returns a NEW, independent Context. Two
contexts for DIFFERENT visitor ids share NO in-memory state — a property
update on one never bleeds into the other. Two contexts for the SAME visitor
id legitimately share the visitor's StoreData THROUGH the store (that is
stickiness, not contamination): in-memory attributes stay per-instance, but
persisted properties round-trip via the shared store.
Visitor store key
All persisted visitor data lives under the {account_id}-{project_id}-{visitor_id}
key built by the single 2.1 key builder (DataStoreManager#visitor_key); the
account / project halves come from the DataManager readers. All stored
visitor data is string-keyed.
Never-crash boundary (NFR9, architecture verbatim)
Every public method wraps its body in rescue StandardError → an error log
line (format Context#method: ...) + the method's per-contract return value
(+nil+ for lookups, self for the chainable mutator). A raising collaborator
degrades the call; it never crashes the host request.
Instance Attribute Summary collapse
-
#attributes ⇒ Hash{String=>Object}
readonly
The in-memory, string-keyed attributes (the merged view subsequent decision methods read).
-
#visitor_id ⇒ String
readonly
The visitor id this context is bound to.
Instance Method Summary collapse
-
#get_config_entity(key, entity_type) ⇒ Hash?
Look up a config entity by key and type from the installed config snapshot.
-
#get_visitor_data ⇒ Hash{String=>Object}
Read this visitor's persisted
StoreDatafrom the store. -
#initialize(visitor_id:, data_manager:, data_store_manager:, event_manager:, log_manager:, config:, attributes: nil, experience_manager: nil, feature_manager: nil, segments_manager: nil, api_manager: nil) ⇒ Context
constructor
A new instance of Context.
-
#run_custom_segments(segment_keys, attributes = nil) ⇒ Sentinel?
Evaluate the named custom segments for this visitor and attach the matching segment ids (FR29; JS
runCustomSegments,context.ts:455-475). -
#run_experience(key, attributes = nil) ⇒ BucketedVariation, Sentinel
Decide a single experience for this visitor and return its variation.
-
#run_experiences(attributes = nil) ⇒ Array<BucketedVariation>
Decide ALL applicable (running) experiences for this visitor and return the list of bucketed variations (FR16).
-
#run_feature(key, attributes = nil) ⇒ BucketedFeature+
Evaluate a SINGLE feature flag for this visitor with typed variables (FR24).
-
#run_features(attributes = nil) ⇒ Array<BucketedFeature>
Evaluate ALL declared feature flags for this visitor with typed variables (FR25).
-
#set_default_segments(segments) ⇒ self
Set default report-segments for this visitor (FR28; JS
setDefaultSegments->SegmentsManager#put_segments,context.ts:434-436). -
#track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false) ⇒ self
Track a conversion for this visitor on
goal_keywith optional revenue / transaction data, deduplicated per visitor per goal (FR31-FR35). -
#update_visitor_properties(properties) ⇒ self
Merge per-visitor properties into BOTH the stored
StoreData(atomically, via DataStoreManager#merge_visitor_data) and the in-memory attributes, so a later decision on THIS context sees the merge immediately (in-memory) and a later context for the same visitor sees it through the store (stickiness).
Constructor Details
#initialize(visitor_id:, data_manager:, data_store_manager:, event_manager:, log_manager:, config:, attributes: nil, experience_manager: nil, feature_manager: nil, segments_manager: nil, api_manager: nil) ⇒ Context
Returns a new instance of Context.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/convert_sdk/context.rb', line 73 def initialize(visitor_id:, data_manager:, data_store_manager:, event_manager:, log_manager:, config:, attributes: nil, experience_manager: nil, feature_manager: nil, segments_manager: nil, api_manager: nil) @visitor_id = visitor_id @data_manager = data_manager @data_store_manager = data_store_manager @event_manager = event_manager @log_manager = log_manager @config = config @experience_manager = experience_manager @feature_manager = feature_manager @segments_manager = segments_manager @api_manager = api_manager # Deep-stringify the caller's attributes ONCE at the boundary; internals # only ever see string keys. nil → empty. The caller's hash is never mutated. @attributes = deep_stringify(attributes || {}) end |
Instance Attribute Details
#attributes ⇒ Hash{String=>Object} (readonly)
Returns the in-memory, string-keyed attributes (the merged view subsequent decision methods read).
96 97 98 |
# File 'lib/convert_sdk/context.rb', line 96 def attributes @attributes end |
#visitor_id ⇒ String (readonly)
Returns the visitor id this context is bound to.
92 93 94 |
# File 'lib/convert_sdk/context.rb', line 92 def visitor_id @visitor_id end |
Instance Method Details
#get_config_entity(key, entity_type) ⇒ Hash?
Look up a config entity by key and type from the installed config snapshot.
entity_type names the collection — :experience / :feature / :goal
(accepted as a symbol or a string; the value is matched verbatim after
to_s, so it must be one of those three lowercase names) — and dispatches
to the matching DataManager by-key reader. A miss (unknown key OR
unknown/unmatched type) returns
nil and emits a debug line
(+Context#get_config_entity: no type found for key=key+) — never a
raise. (JS getConfigEntity — context.ts:495 — returns undefined
silently on a miss; the debug log is a Ruby-specific observability
enhancement.)
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/convert_sdk/context.rb', line 156 def get_config_entity(key, entity_type) type = entity_type.to_s entity = case type when "experience" then @data_manager.experience_by_key(key) when "feature" then @data_manager.feature_by_key(key) when "goal" then @data_manager.goal_by_key(key) end return entity unless entity.nil? @log_manager.debug("Context#get_config_entity: no #{type} found for key=#{key}") nil rescue StandardError => e @log_manager.error("Context#get_config_entity: #{e.class}: #{e.}") nil end |
#get_visitor_data ⇒ Hash{String=>Object}
Read this visitor's persisted StoreData from the store.
Returns the stored, string-keyed StoreData verbatim when present; when the
visitor has no stored entry, returns the empty StoreData shape
{"bucketing"=>{}, "segments"=>{}, "goals"=>{}} (a Ruby-specific stable
shape — JS returns a bare {} — so callers always get the three known
sub-maps to read).
131 132 133 134 135 136 137 138 |
# File 'lib/convert_sdk/context.rb', line 131 def get_visitor_data key = @data_store_manager.visitor_key(account_key, project_key, @visitor_id) stored = @data_store_manager.get(key) stored.is_a?(Hash) ? stored : empty_store_data rescue StandardError => e @log_manager.error("Context#get_visitor_data: #{e.class}: #{e.}") empty_store_data end |
#run_custom_segments(segment_keys, attributes = nil) ⇒ Sentinel?
Evaluate the named custom segments for this visitor and attach the matching
segment ids (FR29; JS runCustomSegments, context.ts:455-475). For each
key the SegmentsManager looks up the segment entity and evaluates its rules
— via the Epic 2 RuleManager — against the visitor's properties (the
context attributes deep-merged with the stored segments and the per-call
ruleData, mirroring JS getVisitorProperties). Matching ids attach under
customSegments in StoreData. A surfaced RuleError sentinel is returned
verbatim; otherwise nil (JS returns the RuleError union or undefined).
NO lifecycle event fires on attachment (JS parity, F-014).
Never raises into the host: a failure degrades to an error log + nil (NFR9).
365 366 367 368 369 370 371 372 373 374 |
# File 'lib/convert_sdk/context.rb', line 365 def run_custom_segments(segment_keys, attributes = nil) manager = @segments_manager return nil if manager.nil? result = manager.select_custom_segments(@visitor_id, segment_keys, visitor_properties(attributes)) result.is_a?(Sentinel) ? result : nil rescue StandardError => e @log_manager.error("Context#run_custom_segments: #{e.class}: #{e.}") nil end |
#run_experience(key, attributes = nil) ⇒ BucketedVariation, Sentinel
Decide a single experience for this visitor and return its variation.
The optional per-call attributes are deep-stringified and merged OVER the
context's own attributes (per-call wins), then handed to the ordered
decision flow (ExperienceManager#select_variation -> DataManager). On a
hit a frozen BucketedVariation is returned and the SystemEvents::BUCKETING
lifecycle event fires (payload {visitor_id, experience_key, variation_key},
deferred for late subscribers — JS context.ts:153-162). On a miss the
matching Sentinel (RuleError/BucketingError) is returned and NO event
fires. The integrator pattern works on both:
case (v = context.run_experience("homepage-test")).key
when nil then render_default # a sentinel miss (key is nil)
else render_variation(v.key) # a real decision
end
Never raises into the host: an internal failure degrades to
RuleError::NO_DATA_FOUND + an error log (NFR9).
Tracking control (Story 4.5)
attributes[:enable_tracking] (or "enable_tracking") is the per-call
tracking switch (snake_case of the JS BucketingAttributes.enableTracking).
When false THIS call still decides and still persists sticky StoreData, but
NO bucketing event is enqueued (a debug line records the suppression). The
global Config tracking: false switch ALWAYS wins over a per-call true.
204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/convert_sdk/context.rb', line 204 def run_experience(key, attributes = nil) manager = @experience_manager return RuleError::NO_DATA_FOUND if manager.nil? @data_manager.ensure_fresh_config! variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes)) fire_bucketing(key, variation, track: tracking_enabled_for_call?(attributes)) unless variation.is_a?(Sentinel) variation rescue StandardError => e @log_manager.error("Context#run_experience: #{e.class}: #{e.}") RuleError::NO_DATA_FOUND end |
#run_experiences(attributes = nil) ⇒ Array<BucketedVariation>
Decide ALL applicable (running) experiences for this visitor and return the list of bucketed variations (FR16). Misses are FILTERED OUT (JS parity — experience-manager.ts:159-168): the list contains ONLY frozen BucketedVariations the visitor was actually bucketed into, never sentinels. The SystemEvents::BUCKETING event fires once per returned variation (JS context.ts:209-222).
context.run_experiences.each { |v| activate(v.experience_key, v.key) }
Never raises into the host: an internal failure degrades to [] + an
error log (NFR9).
attributes[:enable_tracking] == false suppresses the per-variation bucketing
enqueue for THIS call (decisioning + sticky writes unaffected); the global
Config tracking: false switch always wins (Story 4.5).
236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/convert_sdk/context.rb', line 236 def run_experiences(attributes = nil) manager = @experience_manager return [] if manager.nil? @data_manager.ensure_fresh_config! variations = manager.select_variations(@visitor_id, decision_attributes(attributes)) track = tracking_enabled_for_call?(attributes) variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) } variations rescue StandardError => e @log_manager.error("Context#run_experiences: #{e.class}: #{e.}") [] end |
#run_feature(key, attributes = nil) ⇒ BucketedFeature+
Evaluate a SINGLE feature flag for this visitor with typed variables (FR24).
The feature resolves THROUGH experience bucketing (FR26): it is ENABLED exactly when the visitor is bucketed (via the Story 2.11 decision flow) into a variation carrying that feature, and its variables arrive cast to their declared types (FR27 — see FeatureManager#cast_type). On a hit a frozen BucketedFeature (+status: enabled+) is returned; when the same feature is carried by SEVERAL bucketed variations an Array of enabled BucketedFeatures is returned (JS +runFeature+ parity). On a miss — feature undeclared, or the visitor bucketed into no carrying variation — a frozen DISABLED BucketedFeature is returned, never an exception (AC#5).
Branch on #status (never an error sentinel):
feature = context.run_feature("new-checkout")
if feature.status == ConvertSdk::FeatureStatus::ENABLED
render_new_checkout(feature.variables["headline"])
else
render_legacy_checkout
end
NOTE (accepted parity break): JS runFeature accepts an optional
experienceKeys filter argument; this Ruby surface intentionally OMITS it
(deferred feature). Resolution always spans all configured experiences.
Never raises into the host: an internal failure degrades to a DISABLED
BucketedFeature (carrying the requested key) + an error log (NFR9).
282 283 284 285 286 287 288 289 290 291 |
# File 'lib/convert_sdk/context.rb', line 282 def run_feature(key, attributes = nil) manager = @feature_manager return disabled_feature(key) if manager.nil? @data_manager.ensure_fresh_config! manager.run_feature(@visitor_id, key, decision_attributes(attributes)) rescue StandardError => e @log_manager.error("Context#run_feature: #{e.class}: #{e.}") disabled_feature(key) end |
#run_features(attributes = nil) ⇒ Array<BucketedFeature>
Evaluate ALL declared feature flags for this visitor with typed variables
(FR25). Returns the full feature roster: every feature carried by a variation
the visitor was bucketed into is ENABLED (variables cast to declared types);
every other declared feature is DISABLED (JS runFeatures parity, no feature
filter). Misses never surface as exceptions or error sentinels.
context.run_features.each do |feature|
toggle(feature.key, on: feature.status == ConvertSdk::FeatureStatus::ENABLED)
end
Never raises into the host: an internal failure degrades to [] + an
error log (NFR9).
309 310 311 312 313 314 315 316 317 318 |
# File 'lib/convert_sdk/context.rb', line 309 def run_features(attributes = nil) manager = @feature_manager return [] if manager.nil? @data_manager.ensure_fresh_config! manager.run_features(@visitor_id, decision_attributes(attributes)) rescue StandardError => e @log_manager.error("Context#run_features: #{e.class}: #{e.}") [] end |
#set_default_segments(segments) ⇒ self
Set default report-segments for this visitor (FR28; JS setDefaultSegments
-> SegmentsManager#put_segments, context.ts:434-436). The supplied
segments are deep-stringified at this public boundary, then filtered to the
seven JS SegmentsManager::SEGMENTS_KEYS report keys and merged into the
visitor's StoreData["segments"] (non-report keys are dropped). Caller
supplies the JS wire keys (+visitorType+, customSegments, …) — these ARE
the public contract (FR30); the diverged PHP variants are never produced.
NO lifecycle event fires on segment attachment (JS parity — neither
setDefaultSegments nor runCustomSegments fire SystemEvents.SEGMENTS).
Never raises into the host: a failure degrades to an error log and returns
self (NFR9).
336 337 338 339 340 341 342 343 344 345 |
# File 'lib/convert_sdk/context.rb', line 336 def set_default_segments(segments) manager = @segments_manager return self if manager.nil? manager.put_segments(@visitor_id, deep_stringify(segments || {})) self rescue StandardError => e @log_manager.error("Context#set_default_segments: #{e.class}: #{e.}") self end |
#track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false) ⇒ self
Track a conversion for this visitor on goal_key with optional revenue /
transaction data, deduplicated per visitor per goal (FR31-FR35).
The dedup decision + atomic mark live in DataManager#convert (the store
merge lock makes check-then-mark one atomic op — the Android qs-01 fix);
this surface wraps the returned wire-shaped data hash into the
{eventType:'conversion', data:{...}} envelope (co-located with the
bucketing-event construction site for consistency), enqueues it through the
ApiManager (per-visitor merge, non-blocking — NFR2), and fires the
SystemEvents::CONVERSION lifecycle event with deferred: true so a
listener that subscribes AFTER the call still receives the replay (JS
context.ts:416-424). When the conversion is deduplicated or the goal key is
unknown, DataManager#convert returns nil: no event is enqueued and
CONVERSION does NOT fire.
context.track_conversion("purchase", goal_data: { amount: 49.99, transaction_id: "tx-1" })
force_multiple_transactions: true bypasses the dedup check (a legitimate
repeat transaction is enqueued) without re-marking the goal — see
DataManager#convert.
goal_data accepts the eight GoalDataKey platform keys in snake_case
symbol form (+amount:+, products_count:, transaction_id:,
custom_dimension_1: … custom_dimension_5:); unknown keys are rejected
(debug-logged) and emitted as [{key, value}] wire pairs.
Never raises into the host: an internal failure degrades to an error log
and returns self (NFR9).
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/convert_sdk/context.rb', line 410 def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false) # Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a # suppressed conversion neither enqueues NOR marks dedup (the goals[goalId] # mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal # call therefore stays unblocked until tracking is re-enabled. Return value # is unchanged (self); no sentinel. unless @config.tracking @log_manager.debug("Context#track_conversion: tracking disabled, event suppressed") return self end @data_manager.ensure_fresh_config! data = @data_manager.convert( @visitor_id, goal_key, goal_data: goal_data, force_multiple_transactions: force_multiple_transactions ) fire_conversion(goal_key, data) unless data.nil? self rescue StandardError => e @log_manager.error("Context#track_conversion: #{e.class}: #{e.}") self end |
#update_visitor_properties(properties) ⇒ self
Merge per-visitor properties into BOTH the stored StoreData (atomically,
via DataStoreManager#merge_visitor_data) and the in-memory attributes, so
a later decision on THIS context sees the merge immediately (in-memory) and
a later context for the same visitor sees it through the store (stickiness).
Properties are deep-stringified at this public boundary and merged under the
StoreData "segments" sub-key (JS updateVisitorProperties stores
{segments: props} — context.ts:482). The merge is atomic per visitor:
the read-modify-write runs inside the store manager's merge mutex.
110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/convert_sdk/context.rb', line 110 def update_visitor_properties(properties) normalised = deep_stringify(properties || {}) @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current| { "segments" => normalised } end @attributes = @attributes.merge(normalised) self rescue StandardError => e @log_manager.error("Context#update_visitor_properties: #{e.class}: #{e.}") self end |