Class: ConvertSdk::Context

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.

Parameters:

  • visitor_id (String)

    the resolved visitor id (validated non-blank by ConvertSdk::Client#create_context before construction).

  • attributes (Hash, nil) (defaults to: nil)

    the per-visitor attributes; deep-stringified here at the public boundary (nil → {}).

  • data_manager (DataManager)

    the config reader surface (backs #get_config_entity and supplies the account/project key halves).

  • data_store_manager (DataStoreManager)

    the persistence port (atomic visitor-data merge + reads).

  • event_manager (EventManager)

    lifecycle pub/sub (held for the decisioning methods that land in later stories).

  • log_manager (LogManager)

    the redacting logging surface.

  • config (Config)

    the validated configuration surface.

  • experience_manager (ExperienceManager, nil) (defaults to: nil)

    the variation-selection surface backing #run_experience/#run_experiences (Story 2.11). nil leaves the shell decisioning-less (the 2.8 lookup-only construction).

  • feature_manager (FeatureManager, nil) (defaults to: nil)

    the feature-resolution + typed-variable-casting surface backing #run_feature/#run_features (Story 3.1). nil leaves the feature methods miss-only (no decisioning).

  • segments_manager (SegmentsManager, nil) (defaults to: nil)

    the visitor-segmentation surface backing #set_default_segments/#run_custom_segments (Story 3.2). nil leaves the segmentation methods inert (no persistence).

  • api_manager (ApiManager, nil) (defaults to: nil)

    the outbound delivery surface (Story 4.1). When wired, a fresh bucketing decision enqueues a bucketing event at the single #fire_bucketing seam; nil leaves the enqueue inert.



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

#attributesHash{String=>Object} (readonly)

Returns the in-memory, string-keyed attributes (the merged view subsequent decision methods read).

Returns:

  • (Hash{String=>Object})

    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_idString (readonly)

Returns the visitor id this context is bound to.

Returns:

  • (String)

    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 getConfigEntitycontext.ts:495 — returns undefined silently on a miss; the debug log is a Ruby-specific observability enhancement.)

Parameters:

  • key (String)

    the entity key to look up.

  • entity_type (String, Symbol)

    the collection: experience/feature/goal.

Returns:

  • (Hash, nil)

    the frozen entity hash, or nil on a miss.



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.message}")
  nil
end

#get_visitor_dataHash{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).

Returns:

  • (Hash{String=>Object})

    the visitor's StoreData (or the empty shape).



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(, 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.message}")
  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).

Parameters:

  • segment_keys (Array<String>)

    the segment keys to evaluate.

  • attributes (Hash, nil) (defaults to: nil)

    optional {ruleData: {...}} visitor data the segment rules match against (deep-stringified, merged over the context attributes); nil uses the context attributes alone.

Returns:



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.message}")
  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.

Parameters:

  • key (String)

    the experience key.

  • attributes (Hash, nil) (defaults to: nil)

    optional per-call visitor properties merged over the context attributes (deep-stringified). May carry :enable_tracking.

Returns:



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.message}")
  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).

Parameters:

  • attributes (Hash, nil) (defaults to: nil)

    optional per-call visitor properties merged over the context attributes (deep-stringified). May carry :enable_tracking.

Returns:



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.message}")
  []
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).

Parameters:

  • key (String)

    the feature key to evaluate.

  • attributes (Hash, nil) (defaults to: nil)

    optional per-call visitor properties merged over the context attributes (deep-stringified).

Returns:



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.message}")
  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).

Parameters:

  • attributes (Hash, nil) (defaults to: nil)

    optional per-call visitor properties merged over the context attributes (deep-stringified).

Returns:



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.message}")
  []
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).

Parameters:

  • segments (Hash)

    the candidate report-segments (symbol or string keys).

Returns:

  • (self)


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.message}")
  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).

Parameters:

  • experience_id (String)

    the previewed experience's id.

  • variation_id (String)

    the variation id to force.

Returns:

  • (self)


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.message}")
  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).

Parameters:

  • goal_key (String)

    the goal key to convert on.

  • goal_data (Hash, nil) (defaults to: nil)

    optional revenue/transaction data (snake_case symbol keys of the eight platform keys).

  • force_multiple_transactions (Boolean) (defaults to: false)

    bypass the per-goal dedup check.

Returns:

  • (self)


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.message}")
  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.

Parameters:

  • properties (Hash)

    the properties to merge (symbol or string keys).

Returns:

  • (self)


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(, 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.message}")
  self
end