Class: ConvertSdk::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/convert_sdk/client.rb

Overview

The SDK runtime handle returned by create.

+Client+ owns the wiring of the injected managers (config, logging, HTTP, store, events, data) and drives the config lifecycle at construction:

  • Direct data mode (+data:+ supplied) — the inline object is normalised to string keys and installed straight into DataManager; NO config fetch happens (a single network-free path for testing / advanced setups).
  • Fetch mode (+sdk_key:+ only) — config is fetched via +GET config_endpoint/config/sdkKey+ (+?environment=...+ when set) through HttpClient ONLY, with +Authorization: Bearer sdk_key_secret+ attached when a secret is configured. A failed fetch is logged at +warn+ and the client is constructed WITHOUT config (degrade-gracefully, NFR12) — it never raises.

== ready exactly once (FR9)

The first successful config install (fetched OR direct) fires SystemEvents::READY exactly once for the client's lifetime; the once-guard is the +:first+ marker DataManager#install_config computes atomically inside its config mutex. Subsequent installs (Story 2.7's refresh) fire SystemEvents::CONFIG_UPDATED, never +ready+ again.

== Never-crash boundary

Every public method rescues +StandardError+, logs it, and returns its per-contract value — only create's +ArgumentError+ (raised by Config on misconfiguration) is allowed to escape. The endpoints are touched ONLY through HttpClient (the single hardened HTTP port); the Client never touches the network library directly or builds wire headers beyond passing the Bearer header VALUE through the port.

== Decisioning surface (fully wired)

#create_context injects the per-context decisioning managers (ExperienceManager, FeatureManager, SegmentsManager) and the outbound ApiManager into each Context, so a context returned by a factory-built client decides through the real Story 2.9–3.2 machinery and enqueues bucketing / conversion events for delivery. No background threads are started by the Client or the factory (NFR4 lazy start); the refresh timer (Story 2.7), flush/fork/at_exit (Epic 4) wiring is lazy. Constructor injection throughout — no globals.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config:, log_manager:, http_client:, data_store_manager:, event_manager:, data_manager:, api_manager:, experience_manager: nil, feature_manager: nil, segments_manager: nil) ⇒ Client

Returns a new instance of Client.

Parameters:

  • config (Config)

    the validated configuration surface.

  • log_manager (LogManager)

    shared logging surface (secrets armed).

  • http_client (HttpClient)

    the single HTTP port (config fetch only here).

  • data_store_manager (DataStoreManager)

    persistence port (wired; config caching is Story 2.7 — in-memory install only here).

  • event_manager (EventManager)

    lifecycle pub/sub (fires +ready+).

  • data_manager (DataManager)

    holds the deep-frozen config snapshot.

  • api_manager (ApiManager)

    the outbound event queue + delivery surface (Story 4.1) — drives #flush and the bucketing-event enqueue seam.

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

    the variation-selection surface (Story 2.11) injected into every ConvertSdk::Context. nil leaves contexts decisioning-less (the never-crash unit harness builds clients without it).

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

    the feature-resolution surface (Story 3.1) injected into every ConvertSdk::Context. nil leaves features miss-only.

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

    the visitor-segmentation surface (Story 3.2) injected into every ConvertSdk::Context. nil leaves segmentation inert.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/convert_sdk/client.rb', line 66

def initialize(config:, log_manager:, http_client:, data_store_manager:,
               event_manager:, data_manager:, api_manager:,
               experience_manager: nil, feature_manager: nil, segments_manager: nil)
  @config = config
  @log_manager = log_manager
  @http_client = http_client
  @data_store_manager = data_store_manager
  @event_manager = event_manager
  @data_manager = data_manager
  @api_manager = api_manager
  @experience_manager = experience_manager
  @feature_manager = feature_manager
  @segments_manager = segments_manager
  # The lazily-started config-refresh BackgroundTimer (Story 2.7). Created
  # here (interval bound, registered with ForkGuard) but NEVER started in the
  # factory — #ensure_refresh_timer! starts it on first decision-path use
  # (NFR4). A nil data_refresh_interval makes #start a guarded no-op (2.6),
  # so timer-off mode never spawns a thread.
  @refresh_timer = build_refresh_timer
  # Wire the synchronous timer-off refresh callable into the DataManager so
  # its decision-time TTL check (#ensure_fresh_config!) runs one full refresh
  # cycle through the single HTTP port (the I/O happens under DataManager's
  # thundering-herd fetch mutex, never the config mutex).
  @data_manager.refetch = -> { refresh_config }
  bootstrap_config
  # Story 4.4 AC#5 — register the PID-guarded at_exit flush ONCE per client
  # (the gem's ONLY at_exit site; the third and final single-site after the
  # BackgroundTimer thread-spawn site and the ForkGuard _fork site).
  # Registering an at_exit handler creates NO thread (NFR4-safe). The test
  # harness disables live registration (the handler body is tested directly).
  register_at_exit_flush
rescue StandardError => e
  # Construction must never crash the host: log and continue config-less.
  @log_manager.error("Client#initialize: #{e.class}: #{e.message}")
end

Instance Attribute Details

#api_managerApiManager (readonly)

Returns the outbound event queue + delivery surface (Story 4.1).

Returns:

  • (ApiManager)

    the outbound event queue + delivery surface (Story 4.1).



109
110
111
# File 'lib/convert_sdk/client.rb', line 109

def api_manager
  @api_manager
end

#configConfig (readonly)

Returns the configuration this client was built with.

Returns:

  • (Config)

    the configuration this client was built with.



103
104
105
# File 'lib/convert_sdk/client.rb', line 103

def config
  @config
end

#data_managerDataManager (readonly)

Returns the config snapshot reader surface.

Returns:

  • (DataManager)

    the config snapshot reader surface.



106
107
108
# File 'lib/convert_sdk/client.rb', line 106

def data_manager
  @data_manager
end

Instance Method Details

#config_available?Boolean

Returns true once a config snapshot is installed (degrade probe).

Returns:

  • (Boolean)

    true once a config snapshot is installed (degrade probe).



128
129
130
# File 'lib/convert_sdk/client.rb', line 128

def config_available?
  @data_manager.config_available?
end

#create_context(visitor_id = nil, attributes = nil) ⇒ Context?

Create a per-visitor decisioning ConvertSdk::Context — the object an integrator holds for the lifetime of one request/job.

The +visitor_id+ is validated for presence (blank/nil → an +error+ log line

  • +nil+ return, NOT an +ArgumentError+: validation here is request-time, and the never-crash contract forbids raising into the host on a per-request call; only ConvertSdk.create's config-time misconfiguration raises). Creation is the SDK's "first use" trigger, so it fires the lazy-start refresh-timer hook (#ensure_refresh_timer!, NFR4) — no threads start before the first context.

Each call returns a NEW, independent ConvertSdk::Context (no caching, no shared mutable in-memory visitor state across instances — FR12); contexts for the same visitor share only the store-backed StoreData (stickiness). Attributes are deep-stringified at the ConvertSdk::Context boundary.

Parameters:

  • visitor_id (String) (defaults to: nil)

    the visitor id (must be non-blank).

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

    optional per-visitor attributes.

Returns:

  • (Context, nil)

    the new Context, or nil for a blank visitor id.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/convert_sdk/client.rb', line 150

def create_context(visitor_id = nil, attributes = nil)
  if visitor_id.nil? || (visitor_id.respond_to?(:strip) && visitor_id.strip.empty?)
    @log_manager.error("Client#create_context: blank visitor_id; returning nil")
    return nil
  end

  # Story 4.4 — re-arm when a Process.daemon bypass left owner_pid stale.
  # Mirrors ApiManager#guard_fork_boundary and Client#postfork: after rearm!
  # marks the inherited refresh timer dead, the ensure_refresh_timer! call
  # below spawns a fresh thread (BackgroundTimer#start is a no-op only when
  # @running is true; mark_dead resets it to false). The check is a free PID
  # comparison; always false on JRuby.
  ForkGuard.rearm! if ForkGuard.forked?

  # Context creation is "first use" — lazily arm the background refresh timer.
  ensure_refresh_timer!
  build_context(visitor_id, attributes)
rescue StandardError => e
  @log_manager.error("Client#create_context: #{e.class}: #{e.message}")
  nil
end

#ensure_fresh_config!self

Decision-time freshness hook for timer-off mode (Lambda/CLI). Delegates to DataManager#ensure_fresh_config!, which performs an on-demand TTL check and a synchronous, thundering-herd-guarded refetch when the cached config is stale. A no-op when the refresh timer is enabled. Never raises.

Returns:

  • (self)


191
192
193
194
195
196
197
# File 'lib/convert_sdk/client.rb', line 191

def ensure_fresh_config!
  @data_manager.ensure_fresh_config!
  self
rescue StandardError => e
  @log_manager.error("Client#ensure_fresh_config!: #{e.class}: #{e.message}")
  self
end

#ensure_refresh_timer!self

Lazily start the background config-refresh timer (NFR4 — never in the factory). Called at the first decision-path entry (consumed by 2.8/2.11); idempotent (2.6 BackgroundTimer#start is idempotent and re-arms after a fork). A nil +data_refresh_interval+ makes this a guarded no-op (no thread is ever created — timer-off mode). Never raises into the host.

Returns:

  • (self)


178
179
180
181
182
183
184
# File 'lib/convert_sdk/client.rb', line 178

def ensure_refresh_timer!
  @refresh_timer.start
  self
rescue StandardError => e
  @log_manager.error("Client#ensure_refresh_timer!: #{e.class}: #{e.message}")
  self
end

#flush(reason = nil) ⇒ self Also known as: release_queues

Explicitly release the queued visitor events synchronously (FR40). THE single flush entry point — delegates to ApiManager#release_queue, which drains-and-swaps inside the queue lock and POSTs OUTSIDE it (the enqueue path is never blocked on network I/O). A failed POST is logged inside the ApiManager and never raised; the full failed-POST queue-retention behaviour lands in Story 4.2. An empty queue is a no-op.

+release_queues+ is the frozen-name alias (FR40) and shares this exact path.

NOTE (Story 4.4 seam): this is the single point where a ForkGuard PID check will gate the flush in a forked child — added here so all flush callers (explicit, at_exit, timer) inherit it from one place.

Never raises into the host (NFR9 never-crash boundary).

Parameters:

  • reason (String, nil) (defaults to: nil)

    a human-readable release reason (logged).

Returns:

  • (self)


216
217
218
219
220
221
222
# File 'lib/convert_sdk/client.rb', line 216

def flush(reason = nil)
  @api_manager.release_queue(reason)
  self
rescue StandardError => e
  @log_manager.error("Client#flush: #{e.class}: #{e.message}")
  self
end

#on(event) {|payload, err| ... } ⇒ self

Subscribe to a lifecycle event. Public API; delegates to EventManager#on (which normalises SystemEvents constants and matching strings to one key and replays deferred one-shot events to late subscribers).

Parameters:

  • event (String)

    a SystemEvents value or matching string.

Yield Parameters:

  • payload (Object, nil)
  • err (Object, nil)

Returns:

  • (self)


119
120
121
122
123
124
125
# File 'lib/convert_sdk/client.rb', line 119

def on(event, &)
  @event_manager.on(event, &)
  self
rescue StandardError => e
  @log_manager.error("Client#on: #{e.class}: #{e.message}")
  self
end

#postforkself

Manually re-arm the SDK after a fork (Story 4.4 AC#4 — frozen API name +postfork+). The exotic-setup escape hatch: in the default Puma/Unicorn/ Sidekiq deployments the +Process._fork+ hook (ForkGuard) and the PID checks at flush boundaries detect forks AUTOMATICALLY with zero configuration, so +postfork+ is rarely needed. It exists for setups that bypass +_fork+ entirely and never reach a flush boundary in time (or integrators who prefer an explicit +on_worker_boot+/+after_fork+ call, LaunchDarkly-style).

Delegates to the SAME ForkGuard.rearm! path as automatic detection: marks both registered timers dead (lazy re-arm on next use), clears queue ownership in this process, and resets the owning PID. Idempotent (calling it in the owning process simply resets owner_pid to the current PID and re-fires the harmless clears). Never raises into the host (NFR9).

Returns:

  • (self)


240
241
242
243
244
245
246
# File 'lib/convert_sdk/client.rb', line 240

def postfork
  ForkGuard.rearm!
  self
rescue StandardError => e
  @log_manager.error("Client#postfork: #{e.class}: #{e.message}")
  self
end