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
# 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

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



96
97
98
# File 'lib/convert_sdk/context.rb', line 96

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.



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 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.



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.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).



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

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:



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.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.

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:



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.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).

Parameters:

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

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

Returns:



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.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:



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.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:



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.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).

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)


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.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)


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.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.

Parameters:

  • properties (Hash)

    the properties to merge (symbol or string keys).

Returns:

  • (self)


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