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). -
#set_preview(experience_id:, variation_id:) ⇒ self
Force a specific variation of an experience for THIS context — bypassing audiences, segments, locations, the environment check, experience status, variation status/traffic filters, stored decisions, and the bucketing hash for that experience only (qs-03 AC4/AC5).
-
#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 90 91 92 93 94 |
# 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 || {}) # qs-03 (RB-5) preview state — nil until {#set_preview} succeeds. A plain # per-instance ivar (never a class/shared variable): two Contexts NEVER # share this (AC7 isolation). Shape: +{experience_id:, variation_id:, # experience:, experience_key:}+. @preview = nil #: Hash[Symbol, untyped]? end |
Instance Attribute Details
#attributes ⇒ Hash{String=>Object} (readonly)
Returns the in-memory, string-keyed attributes (the merged view subsequent decision methods read).
101 102 103 |
# File 'lib/convert_sdk/context.rb', line 101 def attributes @attributes end |
#visitor_id ⇒ String (readonly)
Returns the visitor id this context is bound to.
97 98 99 |
# File 'lib/convert_sdk/context.rb', line 97 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.)
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/convert_sdk/context.rb', line 174 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).
149 150 151 152 153 154 155 156 |
# File 'lib/convert_sdk/context.rb', line 149 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).
Zero-trace under preview (qs-03 AC6, RB-6)
enable_storage: @preview.nil? threads through to
SegmentsManager#select_custom_segments, suppressing ONLY the persistence
write — rule MATCHING still runs exactly as normal (mirrors JS SDK-6,
context.ts:610 — !this._preview passed the same way).
Never raises into the host: a failure degrades to an error log + nil (NFR9).
512 513 514 515 516 517 518 519 520 521 522 523 |
# File 'lib/convert_sdk/context.rb', line 512 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), enable_storage: @preview.nil? ) 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.
Preview forcing (qs-03 AC4/AC5)
When #set_preview has forced a variation for THIS key on this context,
that forced decision is returned DIRECTLY — bypassing decisioning entirely
(no audience/location/environment/status/traffic/stored-decision/bucketing
walk) and firing NO SystemEvents::BUCKETING event (mirrors JS
context.ts:228-235). Because this check runs FIRST, it naturally takes
precedence over a stored decision or fresh bucketing for that experience;
every OTHER experience on this same context still decides normally.
Zero-trace on OTHER experiences under preview (qs-03 AC6, RB-6)
When THIS context has a preview active but key is NOT the previewed
experience, decisioning proceeds NORMALLY — only tracking/persistence are
suppressed: #decision_attributes threads enable_storage: false so
DataManager#persist_bucketing never writes sticky StoreData, and the
per-call tracking verdict is forced false so #fire_bucketing's
#suppress_bucketing_enqueue? skips the outbound enqueue.
#fire_bucketing ALSO suppresses the in-process SystemEvents::BUCKETING
lifecycle-event fire while a preview is active on this context (JS parity
— context.ts:260: if (!this._preview) { fire BUCKETING }). No event
fires for any experience on a preview-active context.
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/convert_sdk/context.rb', line 318 def run_experience(key, attributes = nil) manager = @experience_manager return RuleError::NO_DATA_FOUND if manager.nil? preview = @preview return forced_preview_variation(preview) if preview && key == preview[:experience_key] @data_manager.ensure_fresh_config! variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes)) track = preview.nil? && tracking_enabled_for_call?(attributes) fire_bucketing(key, variation, track: track) 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).
On a preview-active context (qs-03 AC6, RB-6) the previewed experience is
FORCED to its preview variation here too — run-all forcing is API-agnostic,
matching #run_experience and the PHP/Android/Python/iOS SDKs. Its
normally-decided entry is dropped and the forced variation appended (or
appended outright when the previewed experience is absent from the run-all
set, e.g. a draft resolved only via ?exp=); the forced entry fires no
SystemEvents::BUCKETING event, exactly as #run_experience's forced
branch returns before #fire_bucketing. Every OTHER decided variation is
zero-trace like #run_experience's other-experience branch:
#decision_attributes suppresses the sticky persist and the tracking
verdict is forced false, while the SystemEvents::BUCKETING event still
fires per variation (see #run_experience's doc for the Ruby/JS divergence).
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'lib/convert_sdk/context.rb', line 367 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)) preview = @preview return force_preview_in_run_all(variations, preview) if preview 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).
416 417 418 419 420 421 422 423 424 425 |
# File 'lib/convert_sdk/context.rb', line 416 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).
443 444 445 446 447 448 449 450 451 452 |
# File 'lib/convert_sdk/context.rb', line 443 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).
Zero-trace under preview (qs-03 AC6, RB-6)
enable_storage: @preview.nil? threads through to
SegmentsManager#put_segments, suppressing ONLY the persistence write
(mirrors JS SDK-6, context.ts:571 — !this._preview passed the same way).
Never raises into the host: a failure degrades to an error log and returns
self (NFR9).
476 477 478 479 480 481 482 483 484 485 |
# File 'lib/convert_sdk/context.rb', line 476 def set_default_segments(segments) manager = @segments_manager return self if manager.nil? manager.put_segments(@visitor_id, deep_stringify(segments || {}), enable_storage: @preview.nil?) self rescue StandardError => e @log_manager.error("Context#set_default_segments: #{e.class}: #{e.}") self end |
#set_preview(experience_id:, variation_id:) ⇒ self
Force a specific variation of an experience for THIS context — bypassing
audiences, segments, locations, the environment check, experience status,
variation status/traffic filters, stored decisions, and the bucketing
hash for that experience only (qs-03 AC4/AC5). Mirrors JS
Context#setPreview (+context.ts:143-205+); this Ruby surface is
synchronous — the ?exp= fallback fetch runs through
ApiManager#get_config_by_experience, itself process-wide memoized for
60s (qs-03 AC8), so no async/await equivalent is needed here.
Resolution
The CURRENT installed config is tried first, by id
(DataManager#experience_by_id); when absent, a live
?exp={experience_id} fetch resolves it instead (never touches the
installed config or the store — a previewed experience may be draft/
paused and must never be cached alongside production config). The
resolved experience is held BY REFERENCE, never duped or rebuilt:
DataManager's installed config entities are already deep-frozen (Story
2.7), so simply holding the reference carries none of the JS SDK-7
in-place-mutation risk (mutating a frozen Ruby Hash raises
FrozenError rather than silently corrupting shared state); a fetched
experience is a brand-new object that is never installed anywhere, so it
is never shared to begin with.
Inert on bad input (AC7)
A blank +experience_id+/+variation_id+, an unresolvable experience
(absent from both the installed config and the ?exp= fetch response),
or an unknown variation_id on the resolved experience all leave preview
state UNSET (a warn log, never a raise) — a subsequent
#run_experience on this context decides exactly as if this method had
never been called.
Isolation (AC7)
Preview state lives on THIS Context instance only (a plain ivar) — two
+Context+s, even for the same client/config, never share it.
Never raises into the host: an internal failure degrades to an error
log + leaves preview state unset (NFR9).
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/convert_sdk/context.rb', line 235 def set_preview(experience_id:, variation_id:) return warn_preview_inert("experience_id/variation_id required") if blank?(experience_id) || blank?(variation_id) # Coerce ONCE at this public entry (review round 2) — ids may arrive as # different types (an Integer id read straight off a link param, a # String elsewhere); every downstream reference (resolution, the # decision lookup, the stored @preview hash) threads these coerced # locals instead of the original keyword args, mirroring # {#fetch_preview_experience}'s existing "ids may arrive as different # types" +.to_s+ convention. exp_id = experience_id.to_s var_id = variation_id.to_s experience = resolve_preview_experience(exp_id) return warn_preview_inert("no experience found for id=#{exp_id}", clear: true) if experience.nil? decision = @data_manager.get_preview_decision(experience, var_id) return warn_preview_inert("no variation found for id=#{var_id}", clear: true) if decision.nil? @preview = { experience_id: exp_id, variation_id: var_id, experience: experience, experience_key: experience["key"] } self rescue StandardError => e @log_manager.error("Context#set_preview: #{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).
559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 |
# File 'lib/convert_sdk/context.rb', line 559 def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false) # qs-03 (RB-6) — zero-trace: a preview-active context is a FULL no-op here, # checked BEFORE the global tracking gate below (and BEFORE # DataManager#convert) so NOTHING happens — no enqueue, no CONVERSION # event, and no dedup mark (the mark lives inside #convert's atomic # dedup-and-mark, never reached). Mirrors JS +context.ts:512-519+ # (+if (this._preview) return;+ at the top of +trackConversion+). return self if @preview # 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.
Zero-trace under preview (qs-03 AC6, RB-6)
On a preview-active context ONLY the store write below is skipped — the
in-memory @attributes merge always applies (JS parity: JS's public
updateVisitorProperties has no preview guard of its own, but the
PRIVATE helper it calls to persist DOES skip entirely under preview —
context.ts:626-629, if (this._preview) return; — leaving the
in-memory side unaffected there too). "Per-context scratch" per the
qs-03 spec: a later decision on THIS context still sees the merge; no
trace of it ever reaches the store.
126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/convert_sdk/context.rb', line 126 def update_visitor_properties(properties) normalised = deep_stringify(properties || {}) if @preview.nil? @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current| { "segments" => normalised } end end @attributes = @attributes.merge(normalised) self rescue StandardError => e @log_manager.error("Context#update_visitor_properties: #{e.class}: #{e.}") self end |