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 +getConfigEntity+ — +context.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 +{...}+ 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 +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 +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).

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