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

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



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
# File 'lib/convert_sdk/api_manager.rb', line 70

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 })
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:



101
102
103
# File 'lib/convert_sdk/api_manager.rb', line 101

def queue
  @queue
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.



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

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

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



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

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