Class: TrackRelay::Subscribers::Ga4MeasurementProtocol

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

Overview

GA4 Measurement Protocol server-side subscriber (REQ-08, REQ-11).

POSTs each event to Google Analytics 4 via the Measurement Protocol endpoint:

POST https://www.google-analytics.com/mp/collect
?measurement_id=G-XXXXXXXXXX
&api_secret=<secret>
Content-Type: application/json
Body: { client_id:, user_id:, timestamp_micros:, events: [{name:, params:}] }

Async by default — #deliver runs inside a DeliveryJob (loadbalanced via ActiveJob). Hosts that need inline delivery (e.g. unit-test determinism, low-traffic ingestion) can call Base.synchronous! per REQ-11.

Configuration

Read from Configuration at delivery time (NOT at class-body load time) so credentials lambdas / late-bound configs work:

- `config.ga4_measurement_id` — `G-XXXXXXXXXX`
- `config.ga4_api_secret`     — per-stream MP secret
- `config.ga4_use_eu_endpoint` — when `true`, post to
`https://region1.google-analytics.com/mp/collect`
- `config.ga4_require_browser_client_id` — when `true`, deliver
only for requests carrying a genuine `_ga` cookie (see
{#prepare})
- `config.ga4_enrich_page_context` — when `true`, merge
`page_location` / `page_referrer` / `session_id` /
`engagement_time_msec` into delivered params (see {#prepare})

When ga4_measurement_id or ga4_api_secret is nil, #deliver emits a single Rails.logger.warn and returns without raising — gem-loaded-but-not-configured apps must not crash.

Error contract

#deliver raises:

- {TrackRelay::DeliveryRetriableError} on transient failures
(HTTP 5xx, `Net::OpenTimeout`, `Net::ReadTimeout`,
`Errno::ECONNREFUSED`, `SocketError`). Mapped to
`retry_on` in {DeliveryJob}.
- {TrackRelay::DeliveryDiscardableError} on permanent failures
(HTTP 4xx — defensive: GA4 returns 2xx in practice even on
malformed payloads). Mapped to `discard_on` in {DeliveryJob}.
- {TrackRelay::Ga4ConstraintError} when call-time payload
validation fails AND `config.raise_on_validation_error` is
`true` (dev/test). In production (`raise_on_validation_error
= false`) the violation is logged via `Rails.logger.warn` and
the POST is skipped.

Why typed retriable/discardable exceptions?

ActiveJob's retry_on/discard_on macros only fire on raised exceptions, not returned values. Base#safe_deliver normally rescues any StandardError and returns it (the REQ-23 blanket-rescue contract), so a 5xx retry would never reach the job's retry policy. Base therefore carves these two exception classes out of the rescue: it re-raises them so DeliveryJob can map them to retry_on/discard_on. See test/unit/subscribers/base_retry_passthrough_test.rb.

Constant Summary collapse

ENDPOINT_URL =

GA4 production endpoint (US/global region).

"https://www.google-analytics.com/mp/collect"
ENDPOINT_URL_EU =

GA4 EU-region endpoint, selected via config.ga4_use_eu_endpoint = true.

"https://region1.google-analytics.com/mp/collect"
OPEN_TIMEOUT_SECONDS =

Net::HTTP open timeout (TCP connect).

5
READ_TIMEOUT_SECONDS =

Net::HTTP read timeout (response wait).

10
RESERVED_PARAM_PREFIXES =

GA4-reserved param-name prefixes. Per Scout §2 / REQ-27, params starting with these prefixes must not be sent — GA4 silently drops them.

%w[firebase_ ga_ google_].freeze
ENGAGEMENT_TIME_MSEC =

Nominal engagement time attached alongside session_id when page-context enrichment finds a gtag session cookie. GA4 needs a nonzero value for server events to count toward session activity; the number itself is nominal (Google's Measurement Protocol examples use 100).

100

Instance Method Summary collapse

Methods inherited from Base

coerce_event_set, #except_events, filter, #handle, #only_events, #safe_deliver, #set_filter_overrides!, synchronous!

Instance Method Details

#deliver(payload) ⇒ void

This method returns an undefined value.

POST payload to the GA4 Measurement Protocol endpoint.

See class docs for the full configuration / error contract.

Parameters:

Raises:



144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/track_relay/subscribers/ga4_measurement_protocol.rb', line 144

def deliver(payload)
  config = TrackRelay.config
  measurement_id = config.ga4_measurement_id
  api_secret = config.ga4_api_secret

  if measurement_id.nil? || api_secret.nil?
    warn_missing_credentials(measurement_id, api_secret)
    return
  end

  return unless validate_ga4_payload!(payload)

  post_to_ga4(payload, measurement_id, api_secret, config.ga4_use_eu_endpoint)
end

#prepare(payload) ⇒ EventPayload?

Browser-proof gate + page-context enrichment (issue #1), both opt-in via configuration. Runs at notification time — Current.request is gone by the time an async DeliveryJob performs, so gating and enrichment MUST happen here and ride through the serialized payload.

  • config.ga4_require_browser_client_id: deliver only when the current request carries a genuine _ga cookie; the cookie-derived client_id lands in the delivered context.
  • config.ga4_enrich_page_context: merge page_location, conditional page_referrer, and conditional session_id / engagement_time_msec into the delivered params.

Either path swaps in a copy — the shared payload other subscribers receive during fan-out is never mutated.

Parameters:

Returns:



119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/track_relay/subscribers/ga4_measurement_protocol.rb', line 119

def prepare(payload)
  config = TrackRelay.config
  return payload unless config.ga4_require_browser_client_id || config.ga4_enrich_page_context

  request = Current.request
  client_id = ClientId::Ga.from_request(request)
  return nil if config.ga4_require_browser_client_id && client_id.nil?
  return payload if request.nil?

  hash = payload.to_h
  hash[:params] = hash[:params].merge(page_params(request)) if config.ga4_enrich_page_context
  hash[:context] = hash[:context].merge(client_id: client_id) if client_id
  EventPayload.from_h(hash)
end