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 +StoreData+ from 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_key+ with 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 +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 +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 +[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 +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 |