Class: TrackRelay::Subscribers::Ga4MeasurementProtocol
- 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_idwhen 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
-
#deliver(payload) ⇒ void
POST
payloadto the GA4 Measurement Protocol endpoint. -
#prepare(payload) ⇒ EventPayload?
Browser-proof gate + page-context enrichment (issue #1), both opt-in via configuration.
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.
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_gacookie; the cookie-derived client_id lands in the delivered context.config.ga4_enrich_page_context: mergepage_location, conditionalpage_referrer, and conditionalsession_id/engagement_time_msecinto the delivered params.
Either path swaps in a copy — the shared payload other subscribers receive during fan-out is never mutated.
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 |