Class: ConvertSdk::ApiManager Private

Inherits:
Object
  • Object
show all
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 of config?.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 via Time.now.to_f).

60

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • config (Config)

    the validated configuration (track endpoint, sdk_key, sdk_key_secret).

  • data_manager (DataManager)

    supplies account_id / project_id for the payload and the [project_id] URL substitution.

  • http_client (HttpClient)

    the single hardened HTTP port.

  • event_manager (EventManager)

    fires SystemEvents::API_QUEUE_RELEASED after a release (JS parity).

  • log_manager (LogManager)

    the redacting logging surface.



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

#queueVisitorsQueue (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.

Returns:



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

Parameters:

  • cache_key (String)

Returns:

  • (Hash{String=>Object}, nil)


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_testsInteger

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.

Returns:

  • (Integer)


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.

Parameters:

  • cache_key (String)
  • config (Hash{String=>Object})


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:

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

Parameters:

  • visitor_id (String)

    the visitor the event belongs to.

  • event (Hash{String=>Object})

    a wire-shaped event hash.

  • segments (Hash{String=>Object}, nil) (defaults to: nil)

    report-segments, attached only when this enqueue first creates the visitor's queue entry.



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

Parameters:

  • experience_id (String)

Returns:

  • (Hash{String=>Object}, nil)

    the fetched/memoized config, or nil on a failed fetch.



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.

Parameters:

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

    a human-readable release reason (logged + fired).



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