Class: ConvertSdk::ApiManager Private
- Inherits:
-
Object
- Object
- ConvertSdk::ApiManager
- Defined in:
- lib/convert_sdk/api_manager.rb
Overview
This class is part of a private API. You should avoid using this class if possible, as it may be removed or be changed in the future.
The outbound delivery manager — it owns the VisitorsQueue, the tracking endpoint, queue release, and THE wire-payload builder.
Wire-translation boundary #2 (the only outbound converter)
Config#to_internal is the single INBOUND snake_case=>camelCase site; this
class's payload builder is the single OUTBOUND one. Everything in between —
StoreData, the queued events — is ALREADY wire-shaped, string-keyed data.
The payload is therefore built EXCLUSIVELY here as string-keyed camelCase
hashes and serialized with JSON.generate — never string-concatenated JSON,
never symbol keys anywhere in the wire hashes. The result is byte-identical to
the JS wire contract (+api-manager.ts:197-234+).
The payload shape
{
"accountId" => …, "projectId" => …,
"enrichData" => false, "source" => "ruby-sdk",
"visitors" => [
{ "visitorId" => …, "segments" => {…}?, "events" => [ {…}, … ] }
]
}
POSTed to {track_endpoint with [project_id] replaced}/track/{sdkKey} via the
single HttpClient port (the ConvertAgent User-Agent invariant rides
automatically; an Authorization: Bearer {secret} header is passed through the
port's headers param when a secret is configured — the port enforces the
HTTPS-only guard). An empty queue is a no-op.
enrichData / source (verified against JS source)
enrichData is false: the JS formula is !objectDeepValue(config,'dataStore')
(+api-manager.ts:94+), which is false whenever a dataStore is configured; the
Ruby SDK always provides at least a MemoryStore, and the research register is
silent on treating a MemoryStore-only config as "no store", so JS parity holds.
source is "ruby-sdk" — the Ruby analogue of JS +config?.network?.source ||
'js-sdk'+ (+api-manager.ts:115+).
Lock discipline (NFR2/NFR13)
#release_queue drains the queue with an atomic drain-and-swap INSIDE the queue lock, then builds the payload and performs the HTTP POST OUTSIDE the lock. The enqueue path never blocks the caller on network I/O. A failed POST does NOT raise (the full queue-retention behaviour lands in Story 4.2); it is logged and swallowed so the Client boundary never crashes the host.
Constant Summary collapse
- SOURCE =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
The SDK identifier sent as the tracking payload
source(JS analogue ofconfig?.network?.source || 'js-sdk'— api-manager.ts:115). "ruby-sdk"- ENRICH_DATA =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
JS parity:
!objectDeepValue(config,'dataStore')is false whenever a dataStore is configured, and Ruby always provides one (api-manager.ts:94). false- CONFIG_BY_EXPERIENCE_TTL =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
qs-03 AC8 — the #get_config_by_experience memo TTL in seconds. JS oracle:
CONFIG_BY_EXPERIENCE_TTL(api-manager.ts, ms there, seconds here — Ruby-side math is wall-clock seconds viaTime.now.to_f). 60
Instance Attribute Summary collapse
-
#queue ⇒ VisitorsQueue
readonly
private
The underlying per-visitor event queue.
Class Method Summary collapse
-
.cached_config_by_experience(cache_key) ⇒ Hash{String=>Object}?
private
Read a still-fresh (within CONFIG_BY_EXPERIENCE_TTL) memoized config for
cache_key, ornilwhen absent/expired (qs-03 AC8). -
.config_by_experience_cache_size_for_tests ⇒ Integer
private
Test-only inspection: the current size of @config_by_experience_cache (FIX-1, code review round 1, qs-03 AC8 hardening).
-
.memoize_config_by_experience(cache_key, config) ⇒ void
private
Memoize
configundercache_keyfor CONFIG_BY_EXPERIENCE_TTL seconds (qs-03 AC8). -
.reset_config_by_experience_cache_for_tests! ⇒ void
private
Test-only reset (mirrors ForkGuard.reset_for_tests!) so specs stay order-independent against this process-wide memo.
Instance Method Summary collapse
-
#enqueue(visitor_id, event, segments: nil) ⇒ void
private
Enqueue one wire-shaped event for a visitor (delegates to the queue's per-visitor merge), then drive the two automatic delivery triggers:.
-
#get_config_by_experience(experience_id) ⇒ Hash{String=>Object}?
private
Fetch (or reuse a memoized) config scoped to a single experience — qs-03 AC4 fetch resolution / AC8 memoization, the preview lookup path.
-
#initialize(config:, data_manager:, http_client:, event_manager:, log_manager:) ⇒ ApiManager
constructor
private
A new instance of ApiManager.
-
#release_queue(reason = nil) ⇒ void
private
Release the queue — the SINGLE delivery implementation all three triggers (explicit
flush, size, interval) converge on.
Constructor Details
#initialize(config:, data_manager:, http_client:, event_manager:, log_manager:) ⇒ ApiManager
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Returns a new instance of ApiManager.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/convert_sdk/api_manager.rb', line 163 def initialize(config:, data_manager:, http_client:, event_manager:, log_manager:) @config = config @data_manager = data_manager @http_client = http_client @event_manager = event_manager @log_manager = log_manager @queue = VisitorsQueue.new(log_manager: log_manager) # The SECOND and FINAL BackgroundTimer instance (architecture Decision 6 — # one class, two instances: the refresh timer is 2.7's, this is the flush # timer, owned here). It is built and registered with ForkGuard NOW but # NEVER started in the factory (NFR4 — no threads until first use); it is # lazily started on the first enqueue. A +nil+ flush_interval is the # timer-off mode (BackgroundTimer#start is then a guarded no-op — the # Lambda recipe for 4.6: explicit flush + size trigger still deliver). @flush_timer = BackgroundTimer.new( interval: @config.flush_interval, log_manager: log_manager, name: "flush" ) { flush_tick } ForkGuard.register_timer(@flush_timer) # Story 4.4 — child queue-ownership clear. ForkGuard fires this callback in # a forked child (after marking timers dead). The child inherits a COPY of # the parent's queued events; clearing it here is what makes the child # start EMPTY so it never double-delivers the parent's events (the parent's # timer still runs there and delivers them). ForkGuard stays generic — it # knows nothing about the queue; ApiManager owns its own clear (architecture # Decision 6 callback-registry design). ForkGuard.register_child_callback(-> { clear_queue_ownership }) # qs-03 AC8 — clear the process-wide config-by-experience memo in a # forked child (see {ApiManager.reset_config_by_experience_cache_for_tests!} # doc for the rationale). Registered ALONGSIDE (not replacing) the # queue-ownership-clear callback above — ForkGuard fires every # registered callback in registration order. ForkGuard.register_child_callback(-> { ApiManager.reset_config_by_experience_cache_for_tests! }) end |
Instance Attribute Details
#queue ⇒ VisitorsQueue (readonly)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Returns the underlying per-visitor event queue.
200 201 202 |
# File 'lib/convert_sdk/api_manager.rb', line 200 def queue @queue end |
Class Method Details
.cached_config_by_experience(cache_key) ⇒ Hash{String=>Object}?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Read a still-fresh (within CONFIG_BY_EXPERIENCE_TTL) memoized config
for cache_key, or nil when absent/expired (qs-03 AC8).
90 91 92 93 94 95 |
# File 'lib/convert_sdk/api_manager.rb', line 90 def cached_config_by_experience(cache_key) @config_by_experience_mutex.synchronize do entry = @config_by_experience_cache[cache_key] entry["config"] if entry && Time.now.to_f < entry["expires_at"] end end |
.config_by_experience_cache_size_for_tests ⇒ Integer
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Test-only inspection: the current size of @config_by_experience_cache
(FIX-1, code review round 1, qs-03 AC8 hardening). Lets specs assert
that memoize_config_by_experience's expired-entry sweep actually
shrinks the process-wide Hash — not merely that an expired LOOKUP
returns nil. experience_id is operator/attacker-influenced (the
convert_preview={expId}.{varId} link param), so without a sweep a
stream of distinct ids would accumulate unbounded entries in a
long-lived process (Puma/Rails worker). Mirrors
reset_config_by_experience_cache_for_tests!'s test-only scope and
mutex discipline.
150 151 152 |
# File 'lib/convert_sdk/api_manager.rb', line 150 def config_by_experience_cache_size_for_tests @config_by_experience_mutex.synchronize { @config_by_experience_cache.size } end |
.memoize_config_by_experience(cache_key, config) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Memoize config under cache_key for CONFIG_BY_EXPERIENCE_TTL
seconds (qs-03 AC8). A failed fetch must NEVER reach this method (the
caller only memoizes on success).
FIX-1 (code review round 1) — sweeps every OTHER already-expired entry
from the process-wide cache before inserting, mirroring the JS oracle
(+api-manager.ts+ ~367-370, which does the same on every
getConfigByExperience write). experience_id is
operator/attacker-influenced (the convert_preview={expId}.{varId}
link param), so without this sweep a stream of distinct ids would grow
this Hash without bound in a long-lived process (a Puma/Rails worker) —
it would otherwise only shrink on fork or process exit.
113 114 115 116 117 118 119 120 121 122 |
# File 'lib/convert_sdk/api_manager.rb', line 113 def memoize_config_by_experience(cache_key, config) @config_by_experience_mutex.synchronize do now = Time.now.to_f @config_by_experience_cache.delete_if { |_key, entry| entry["expires_at"] <= now } @config_by_experience_cache[cache_key] = { "config" => config, "expires_at" => now + CONFIG_BY_EXPERIENCE_TTL } end end |
.reset_config_by_experience_cache_for_tests! ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Test-only reset (mirrors ForkGuard.reset_for_tests!) so specs stay
order-independent against this process-wide memo. Also the FORK-side
clear (registered per-instance in #initialize via
ForkGuard.register_child_callback): even though a stale memo isn't
unsafe to serve, clearing it on fork matches this file's existing
queue-ownership-clear discipline (#clear_queue_ownership) and avoids
a forked child transparently serving a parent process's stale
preview-experience config across a fork boundary.
134 135 136 |
# File 'lib/convert_sdk/api_manager.rb', line 134 def reset_config_by_experience_cache_for_tests! @config_by_experience_mutex.synchronize { @config_by_experience_cache = {} } end |
Instance Method Details
#enqueue(visitor_id, event, segments: nil) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Enqueue one wire-shaped event for a visitor (delegates to the queue's per-visitor merge), then drive the two automatic delivery triggers:
- LAZY-START the flush timer (NFR4 — the first enqueue in each process is "first use"; idempotent + re-arms after a fork via 2.6's BackgroundTimer).
- SIZE trigger — when the queue reaches
event_batch_size, release with reason"size"DIRECTLY on this thread (JS api-manager.ts:197-198). The enqueue itself is pure in-memory and the size-trigger release POSTs OUTSIDE the queue lock, so the caller is never blocked on the network (NFR2) — only the brief queue-lock acquisition.
218 219 220 221 222 223 |
# File 'lib/convert_sdk/api_manager.rb', line 218 def enqueue(visitor_id, event, segments: nil) guard_fork_boundary @queue.enqueue(visitor_id, event, segments: segments) ensure_flush_timer! release_queue("size") if @queue.size >= @config.event_batch_size end |
#get_config_by_experience(experience_id) ⇒ Hash{String=>Object}?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Fetch (or reuse a memoized) config scoped to a single experience — qs-03
AC4 fetch resolution / AC8 memoization, the preview lookup path. Always
requests _conv_low_cache=1 (bypassing the CDN cache) and scopes the
response with exp=<experience_id>; includes environment /
debug_token when configured. JS oracle: getConfigByExperience
(api-manager.ts ~344).
Memoized PROCESS-WIDE (see CONFIG_BY_EXPERIENCE_TTL), keyed
"{sdk_key}:{experience_id}" — repeated lookups within the TTL window,
including from a DIFFERENT ConvertSdk::ApiManager instance sharing the same
sdk_key, reuse the same fetch. Never touches the store (qs-03: "TTL
60s (in-memory only; never the store)") — the memo lives entirely in the
class-level cache.
On a failed fetch (transport failure, non-2xx, or a non-Hash body):
returns nil WITHOUT memoizing, so the next lookup retries (graceful
degradation, NFR9 — never raises).
290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/convert_sdk/api_manager.rb', line 290 def get_config_by_experience(experience_id) # Data-only mode guard (review round 2) — a direct-data Config has no # sdk_key, and therefore no config-fetch endpoint to hit; without this # guard a preview resolution reachable in that mode would build a # +.../config/?exp=…+ URL with a nil key and fire a guaranteed-failing # HTTP request. return nil if @config.sdk_key.nil? cache_key = "#{@config.sdk_key}:#{experience_id}" cached = self.class.cached_config_by_experience(cache_key) return cached unless cached.nil? fetch_config_by_experience(cache_key, experience_id) end |
#release_queue(reason = nil) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Release the queue — the SINGLE delivery implementation all three triggers
(explicit flush, size, interval) converge on. Drain-and-swap INSIDE the
queue lock, then build the wire payload and POST it OUTSIDE the lock (the
enqueue path is never blocked on network I/O — NFR2). An empty queue is a
no-op.
On SUCCESS: an info line and the SystemEvents::API_QUEUE_RELEASED
lifecycle event fire with a JS-parity payload (+reason+ + visitor count).
On FAILURE (a failed HttpClient::Response — story 1.5 returns it WITHOUT
raising): the drained visitors are RE-ENQUEUED via VisitorsQueue#requeue
(preserving per-visitor merge), a warn records the retention, and NO
event fires. There is NO inline retry — a frozen divergence from PHP's
3-attempt backoff; the next attempt is the next timer tick or size trigger.
The bounded queue (drop-oldest + warn at the 1000 cap) keeps a sustained
outage from growing host memory without bound (NFR10).
Never raises into the host (NFR9): a rescue StandardError logs and
swallows. Note the re-enqueue happens BEFORE the rescue so a transport-layer
failed Response retains; a raise from the rescue path itself (after the
drain) cannot retain, but the never-crash contract takes precedence there.
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/convert_sdk/api_manager.rb', line 249 def release_queue(reason = nil) # Story 4.4 — the SINGLE fork-safety PID boundary all three flush triggers # (explicit flush, size, interval) inherit from one place. A cheap # ForkGuard.forked? check (an integer PID comparison — Datadog idiom) covers # the Process.daemon path that BYPASSES the _fork hook: a stale process # re-arms (marks the inherited dead timers dead, clears the inherited queue, # resets owner_pid) BEFORE proceeding. The check fires BEFORE the # empty-queue early return so a freshly daemonised process re-arms its # timers even when nothing is queued yet. guard_fork_boundary visitors = @queue.drain! return if visitors.empty? deliver(visitors, reason) rescue StandardError => e # Never-crash boundary: a delivery failure must not crash the host. @log_manager.error("ApiManager#release_queue: #{e.class}: #{e.}") end |