Class: TrackRelay::Subscribers::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/track_relay/subscribers/base.rb

Overview

Base class for all track_relay subscribers.

Each subscriber receives an EventPayload via #handle, which routes to one of two paths:

- **sync** — `safe_deliver(payload)` is invoked inline on the
calling thread. Used when the subclass calls {.synchronous!}
or when {Configuration#force_synchronous} is `true`.
- **async** — {DeliveryJob} is enqueued with the subscriber's
class name and the payload's serialized form. The job calls
`safe_deliver` on a fresh instance when it eventually runs.

Error contract (locked in 01-CONTEXT.md, 01-05-PLAN.md): safe_deliver returns nil on success or the StandardError on failure — it NEVER re-raises inline. The Dispatcher collects those returns during fan-out and re-raises the first one afterwards, but only when Configuration#swallow_subscriber_errors is false. This guarantees that one bad subscriber never blocks peers, while still letting dev/test surface failures loudly once everyone has had their chance.

Direct Known Subclasses

Ahoy, Ga4MeasurementProtocol, Logger, Test

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.coerce_event_set(value) ⇒ Set<Symbol>?

Coerce a filter input (Array<Symbol|String>, Set, single Symbol, or nil) into a Set<Symbol> or nil. Internal helper shared by the class-level filter DSL and the per-instance override path (#set_filter_overrides!, used by TrackRelay.subscribe).

Parameters:

  • value (Array, Set, Symbol, String, nil)

Returns:

  • (Set<Symbol>, nil)


81
82
83
84
# File 'lib/track_relay/subscribers/base.rb', line 81

def self.coerce_event_set(value)
  return nil if value.nil?
  Set.new(Array(value).map(&:to_sym))
end

.filter(only: nil, except: nil) ⇒ void

This method returns an undefined value.

Class-level DSL for declaring an event-name filter.

class MySubscriber < TrackRelay::Subscribers::Base
filter only: %i[purchase sign_up]
end

only: and except: are mutually exclusive in spirit but not enforced as such — if both are set, only: wins (an event must be in the allow-list AND not in the deny-list to pass). Pass nil to clear a previously set filter.

Parameters:

  • only (Array<Symbol, String>, nil) (defaults to: nil)

    allow-list; if non-nil, only events whose name is in this set are delivered.

  • except (Array<Symbol, String>, nil) (defaults to: nil)

    deny-list; events in this set are dropped.



69
70
71
72
# File 'lib/track_relay/subscribers/base.rb', line 69

def self.filter(only: nil, except: nil)
  self.only_events = coerce_event_set(only)
  self.except_events = coerce_event_set(except)
end

.synchronous!Boolean

Mark this subclass as synchronous. Calls to #handle will run safe_deliver inline rather than enqueueing a DeliveryJob.

Returns:

  • (Boolean)

    true



49
50
51
# File 'lib/track_relay/subscribers/base.rb', line 49

def self.synchronous!
  self.synchronous = true
end

Instance Method Details

#deliver(payload) ⇒ void

This method returns an undefined value.

Implement in subclasses to receive an EventPayload.

Parameters:

Raises:

  • (NotImplementedError)

    when not overridden



91
92
93
# File 'lib/track_relay/subscribers/base.rb', line 91

def deliver(payload)
  raise NotImplementedError, "#{self.class.name} must implement #deliver(payload)"
end

#except_eventsSet<Symbol>?

Read the effective except: filter for this instance — the singleton override (set by TrackRelay.subscribe) when present, otherwise the class-level default declared via filter.

Returns:

  • (Set<Symbol>, nil)


221
222
223
224
225
226
227
# File 'lib/track_relay/subscribers/base.rb', line 221

def except_events
  if singleton_class.instance_variable_defined?(:@except_events_override)
    singleton_class.instance_variable_get(:@except_events_override)
  else
    self.class.except_events
  end
end

#handle(payload) ⇒ nil, StandardError

Route payload to the sync or async path.

Returns: nil on success, the StandardError on a sync failure, or nil on the async path (the job runs later — its eventual failure mode is handled inside DeliveryJob#perform).

Filter gate (Plan 02-01): if only_events / except_events exclude payload.name, return nil immediately — BEFORE the sync/async branch and BEFORE safe_deliver's rescue boundary. A filtered event with a buggy #deliver therefore neither runs nor logs.

Parameters:

Returns:

  • (nil, StandardError)


109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/track_relay/subscribers/base.rb', line 109

def handle(payload)
  return nil if filtered?(payload.name.to_sym)

  payload = prepare(payload)
  return nil if payload.nil?

  if self.class.synchronous || TrackRelay.config.force_synchronous
    safe_deliver(payload)
  else
    DeliveryJob.perform_later(self.class.name, payload.to_h)
    nil
  end
end

#only_eventsSet<Symbol>?

Read the effective only: filter for this instance — the singleton override (set by TrackRelay.subscribe) when present, otherwise the class-level default declared via filter.

Returns:

  • (Set<Symbol>, nil)


208
209
210
211
212
213
214
# File 'lib/track_relay/subscribers/base.rb', line 208

def only_events
  if singleton_class.instance_variable_defined?(:@only_events_override)
    singleton_class.instance_variable_get(:@only_events_override)
  else
    self.class.only_events
  end
end

#prepare(payload) ⇒ EventPayload?

Notification-time hook, called by #handle after the event-name filter and BEFORE the sync/async branch — i.e. while the request (Current.request) is still in scope, which is gone by the time an async DeliveryJob runs.

Subclasses override this to gate delivery (return nil to drop the event silently — no job, no deliver) or to swap in an enriched copy of the payload. The same payload object fans out to every subscriber, so implementations must never mutate payload — build a new EventPayload instead.

Parameters:

Returns:

  • (EventPayload, nil)

    the payload to deliver, or nil to drop



136
137
138
# File 'lib/track_relay/subscribers/base.rb', line 136

def prepare(payload)
  payload
end

#safe_deliver(payload) ⇒ nil, StandardError

Wrap #deliver with the per-subscriber rescue.

Returns nil on success or the StandardError on failure. ALWAYS logs the failure (via Rails.logger.error) when running under Rails. NEVER re-raises arbitrary StandardErrors — the Dispatcher (or DeliveryJob) makes the loudness decision based on Configuration#swallow_subscriber_errors.

REQ-23 carve-out (Plan 02-04): DeliveryRetriableError and DeliveryDiscardableError are RE-RAISED unconditionally — even when swallow_subscriber_errors = true (the production default). ActiveJob's retry_on / discard_on macros only fire on raised exceptions; without this carve-out the GA4 retry/discard policy in DeliveryJob would be silently broken in production because safe_deliver would catch the exception, return it as a value, and the job would think delivery succeeded.

The carve-out is INTENTIONALLY NARROW: arbitrary StandardErrors still flow through the existing log-and-return path — REQ-23's blanket-rescue contract is preserved for everything outside of the typed retry/discard exception classes.

Parameters:

Returns:

  • (nil, StandardError)


165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/track_relay/subscribers/base.rb', line 165

def safe_deliver(payload)
  deliver(payload)
  nil
rescue TrackRelay::DeliveryRetriableError, TrackRelay::DeliveryDiscardableError
  # Carve-out: ActiveJob retry_on/discard_on must see these.
  # Do NOT log here — the DeliveryJob's retry path will log on
  # eventual exhaustion, and the discard path is an intentional
  # drop. Logging on every retry attempt would spam the log with
  # transient blips that resolve on retry.
  raise
rescue => e
  log_failure(e)
  e
end

#set_filter_overrides!(only: nil, except: nil) ⇒ self

Set per-instance only: / except: filter overrides on this subscriber. Used by TrackRelay.subscribe so a single subscriber class can be registered multiple times with different filters.

Each non-nil argument is coerced via coerce_event_set and stored on the instance's singleton class so it does not bleed across instances or mutate the class-level defaults declared via filter. Passing nil for either argument leaves that override untouched (the instance falls through to the class default).

Parameters:

  • only (Array<Symbol, String>, Set, nil) (defaults to: nil)
  • except (Array<Symbol, String>, Set, nil) (defaults to: nil)

Returns:

  • (self)


193
194
195
196
197
198
199
200
201
# File 'lib/track_relay/subscribers/base.rb', line 193

def set_filter_overrides!(only: nil, except: nil)
  unless only.nil?
    singleton_class.instance_variable_set(:@only_events_override, self.class.coerce_event_set(only))
  end
  unless except.nil?
    singleton_class.instance_variable_set(:@except_events_override, self.class.coerce_event_set(except))
  end
  self
end