track_relay 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ef792bdf08a7044b2505ba7d864b6efd9e52300227bd90ba6d4556833ea4317
4
- data.tar.gz: d5fa187c552b77560ccb373546702ae563b692708ac0e3aebee4b8ee96d9a90f
3
+ metadata.gz: 918577fceae3974911227a1b988f8d17ecf636c086b7f91ad9b8e36ac6d2b9fd
4
+ data.tar.gz: 1dcd1c4f9bb900b0b3945a40ae0222b3205212f52477e10640baa0facc39ae40
5
5
  SHA512:
6
- metadata.gz: d092e315a251dd34b3d29f2b61042dd6b05deadd4bd1b99cad515b28502bdb049bc4009ca2e71a3b6c3375791dc37a81e93c451ad90756d0e846142525d2fa84
7
- data.tar.gz: d9c406cdda1cf1517fc568b6f0589ce3d3655c0402c33151cc520fe2f2f054f242f4e35cd3d98b9f911919bb25da3be6dca07f80a192f084d404bab20c56542b
6
+ metadata.gz: c95d0181afcad7811afaff649155f78681f41a2d54fc3f3ebaf1057f56e4b146d5704a6954e83183d9ef78269a807b648545cc493b42e22c9b93dd53084c9fd2
7
+ data.tar.gz: 9d0c93c207fbdfa07237d9f25657053626711ac0eedf576fd0bb36db84203b67e716bd0f382face10074cbacb4a24842a7e56ac1c7b7adc82ac59aed66036fd3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-07-07
11
+
12
+ ### Added
13
+ - `config.ga4_require_browser_client_id` (default `false`) — browser-proof
14
+ delivery gate for `Subscribers::Ga4MeasurementProtocol`. When enabled,
15
+ events are delivered only when the current request carries a genuine
16
+ `_ga` cookie (set by gtag JS, which bots don't execute). No cookie — or a
17
+ malformed one with fewer than four dot-segments — enqueues no
18
+ `DeliveryJob` at all; the random per-event client_id fallback never
19
+ fires. The cookie-derived client_id rides in the delivered payload's
20
+ context.
21
+ - `config.ga4_enrich_page_context` (default `false`) — notification-time
22
+ page/session enrichment for GA4 MP events: `page_location` (request
23
+ URL), `page_referrer` (when a referer exists), and `session_id` +
24
+ `engagement_time_msec` (nominal 100) when the gtag `_ga_<stream>`
25
+ session cookie is parseable. Captured while the request is in scope and
26
+ serialized through the payload, so async deliveries carry it. Without
27
+ these params GA4 files server-side events under a blank page path.
28
+ - `config.track_gate` (default `nil`) — host-level fan-out gate. An
29
+ optional callable receiving `payload:` and `request:` keywords,
30
+ evaluated once per `TrackRelay.track` call before the event notification
31
+ fires; a falsy return drops the event for every subscriber. Enables
32
+ e.g. "only track requests that prove a real browser" across Ahoy, GA4,
33
+ and Logger at once.
34
+ - `Subscribers::Base#prepare(payload)` — public notification-time hook
35
+ (filter → prepare → sync/async) for subclasses to gate delivery
36
+ (return `nil`) or swap in an enriched copy of the payload while the
37
+ request is still in scope.
38
+ - `ClientId::Ga.parse` / `ClientId::Ga.from_request` — the `_ga` parse
39
+ rule as reusable class methods (the resolver-chain `#call` now
40
+ delegates to them).
41
+
42
+ ### Changed
43
+ - All new options default off: an unconfigured upgrade is byte-identical
44
+ to 1.0.0 (the pre-existing test suite passes unmodified).
45
+
10
46
  ## [1.0.0] - 2026-05-07
11
47
 
12
48
  ### Added
@@ -141,6 +177,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
177
  - Privacy: untyped JSONL captures param NAMES only (never VALUES) to avoid leaking PII.
142
178
  - Naming: `track_relay` availability on RubyGems will be re-validated before 1.0.
143
179
 
180
+ [1.1.0]: https://github.com/dchuk/track_relay/compare/v1.0.0...v1.1.0
144
181
  [1.0.0]: https://github.com/dchuk/track_relay/compare/v0.3.0...v1.0.0
145
182
  [0.3.0]: https://github.com/dchuk/track_relay/releases/tag/v0.3.0
146
183
  [0.2.0]: https://github.com/dchuk/track_relay/releases/tag/v0.2.0
data/README.md CHANGED
@@ -334,6 +334,46 @@ TrackRelay.subscribe(
334
334
  )
335
335
  ```
336
336
 
337
+ ### Browser-proof gating + page context (opt-in)
338
+
339
+ Server-side GA4 events have two chronic problems: bots with browser user agents inflate user counts (the Measurement Protocol accepts any client_id, and the subscriber's fallback mints a random one per event), and events land under a blank page path because nothing sends `page_location`. Two opt-in flags fix both:
340
+
341
+ ```ruby
342
+ TrackRelay.configure do |c|
343
+ # Deliver ONLY when the current request carries a genuine `_ga`
344
+ # cookie — set by gtag JS, which bots don't execute. No cookie (or a
345
+ # malformed one) means no DeliveryJob is enqueued at all; the random
346
+ # client_id fallback never fires. The cookie-derived client_id is
347
+ # used for the delivery.
348
+ c.ga4_require_browser_client_id = true
349
+
350
+ # Capture page context from the request at notification time and
351
+ # send it with every GA4 event: `page_location` (request URL),
352
+ # `page_referrer` (when a referer exists), and `session_id` +
353
+ # `engagement_time_msec` when the gtag `_ga_<stream>` session cookie
354
+ # is present.
355
+ c.ga4_enrich_page_context = true
356
+ end
357
+ ```
358
+
359
+ Both run inside the subscriber's notification-time `prepare` hook — the request is still in scope there, and the captured values ride through the serialized payload to the async `DeliveryJob`. Other subscribers always receive the original, unmodified payload.
360
+
361
+ ### Host-level track gate (opt-in)
362
+
363
+ `config.track_gate` is evaluated once per `TrackRelay.track` call, before the event fans out to **any** subscriber. A falsy return drops the event everywhere (Ahoy, GA4, Logger, …). It receives `payload:` and `request:` keywords:
364
+
365
+ ```ruby
366
+ TrackRelay.configure do |c|
367
+ # Only track requests that prove a real browser — aligns first-party
368
+ # analytics (e.g. Ahoy) with what GA4 sees under the browser gate.
369
+ c.track_gate = ->(payload:, request:) {
370
+ TrackRelay::ClientId::Ga.from_request(request)
371
+ }
372
+ end
373
+ ```
374
+
375
+ Unset (`nil`, the default) disables the gate entirely.
376
+
337
377
  ### `client_id` resolver chain
338
378
 
339
379
  `TrackRelay::Current.client_id` is resolved via a configurable chain of `client_id_resolvers`. The default chain checks the GA `_ga` cookie, then any Ahoy visitor token, then mints a session-stable UUID into `session[:track_relay_client_id]` so visitors without a `_ga` cookie still get a stable identifier. First non-nil wins; per-resolver exceptions are isolated so a single buggy resolver cannot block the chain.
@@ -28,6 +28,35 @@ module TrackRelay
28
28
  # and yield `nil` (callers should fall through to the next resolver
29
29
  # in the chain).
30
30
  class Ga
31
+ # Parse a raw `_ga` cookie value into a GA4 client_id.
32
+ #
33
+ # This is THE canonical `_ga` parse rule — the resolver chain
34
+ # ({#call}) and the browser-proof delivery gate
35
+ # ({Subscribers::Ga4MeasurementProtocol#prepare}) both delegate
36
+ # here so the "fewer than four segments = malformed = nil"
37
+ # posture is defined exactly once.
38
+ #
39
+ # @param cookie_value [String, nil] raw `_ga` cookie value
40
+ # @return [String, nil] the parsed GA4 client_id, or `nil` when
41
+ # the value is missing/empty/malformed.
42
+ def self.parse(cookie_value)
43
+ return nil if cookie_value.nil? || cookie_value.empty?
44
+
45
+ parts = cookie_value.split(".")
46
+ return nil if parts.size < 4
47
+
48
+ "#{parts[-2]}.#{parts[-1]}"
49
+ end
50
+
51
+ # Extract and parse the `_ga` cookie from a request-like object.
52
+ #
53
+ # @param request [#cookies, nil] any object exposing
54
+ # `request.cookies["_ga"]` (e.g. an `ActionDispatch::Request`).
55
+ # @return [String, nil]
56
+ def self.from_request(request)
57
+ parse(request&.cookies&.[]("_ga"))
58
+ end
59
+
31
60
  # @param controller [#request] any object exposing
32
61
  # `controller.request.cookies["_ga"]`. Typically an
33
62
  # `ActionController::Base` instance from
@@ -35,13 +64,7 @@ module TrackRelay
35
64
  # @return [String, nil] the parsed GA4 client_id, or `nil` when the
36
65
  # cookie is missing/empty/malformed.
37
66
  def call(controller:, **)
38
- ga_cookie = controller&.request&.cookies&.[]("_ga")
39
- return nil if ga_cookie.nil? || ga_cookie.empty?
40
-
41
- parts = ga_cookie.split(".")
42
- return nil if parts.size < 4
43
-
44
- "#{parts[-2]}.#{parts[-1]}"
67
+ self.class.from_request(controller&.request)
45
68
  end
46
69
  end
47
70
  end
@@ -50,6 +50,30 @@ module TrackRelay
50
50
  # When `true`, the GA4 subscriber posts to
51
51
  # `https://region1.google-analytics.com/mp/collect` instead of
52
52
  # the global endpoint. Defaults to `false`.
53
+ # @!attribute [rw] ga4_require_browser_client_id
54
+ # When `true`, the GA4 subscriber delivers ONLY when the current
55
+ # request carries a genuine `_ga` cookie (set by gtag JS, which
56
+ # bots don't execute). No cookie — or a malformed one — means no
57
+ # DeliveryJob is enqueued at all; the random client_id fallback
58
+ # never fires. The cookie-derived client_id is merged into the
59
+ # delivered payload's context. Defaults to `false` (deliver
60
+ # everything, as before).
61
+ # @!attribute [rw] ga4_enrich_page_context
62
+ # When `true`, the GA4 subscriber captures page context from the
63
+ # current request at notification time and merges it into the
64
+ # delivered event's params: `page_location` (request URL),
65
+ # `page_referrer` (when a referer exists), and — when the gtag
66
+ # session cookie `_ga_<stream>` is parseable — `session_id` plus
67
+ # a nominal `engagement_time_msec`. Without these GA4 files
68
+ # server-side events under a blank page path. Defaults to
69
+ # `false`.
70
+ # @!attribute [rw] track_gate
71
+ # Optional callable evaluated once per {TrackRelay.track} call,
72
+ # BEFORE the event notification fans out to any subscriber.
73
+ # Receives `payload:` (the built {EventPayload}) and `request:`
74
+ # ({Current.request}, possibly `nil`) keywords; a falsy return
75
+ # drops the event for every subscriber. `nil` (the default)
76
+ # disables the gate entirely — current behavior.
53
77
  attr_accessor :swallow_subscriber_errors,
54
78
  :untyped_log_path,
55
79
  :untyped_events_allowed,
@@ -58,7 +82,10 @@ module TrackRelay
58
82
  :client_id_resolvers,
59
83
  :ga4_measurement_id,
60
84
  :ga4_api_secret,
61
- :ga4_use_eu_endpoint
85
+ :ga4_use_eu_endpoint,
86
+ :ga4_require_browser_client_id,
87
+ :ga4_enrich_page_context,
88
+ :track_gate
62
89
 
63
90
  # @return [Array] registered subscriber instances, in insertion order
64
91
  attr_reader :subscribers
@@ -82,6 +109,9 @@ module TrackRelay
82
109
  @ga4_measurement_id = nil
83
110
  @ga4_api_secret = nil
84
111
  @ga4_use_eu_endpoint = false
112
+ @ga4_require_browser_client_id = false
113
+ @ga4_enrich_page_context = false
114
+ @track_gate = nil
85
115
  end
86
116
 
87
117
  # Append a subscriber to the registry.
@@ -82,6 +82,7 @@ module TrackRelay
82
82
  extra_context: direct_context
83
83
  )
84
84
  return unless validate(payload)
85
+ return unless track_gate_allows?(payload)
85
86
  ActiveSupport::Notifications.instrument(NOTIFICATION, event: payload)
86
87
  end
87
88
  end
@@ -215,6 +216,21 @@ module TrackRelay
215
216
  }
216
217
  end
217
218
 
219
+ # Evaluate {Configuration#track_gate} once per track call, inside
220
+ # the `Current.set` scope so the gate sees the same request the
221
+ # subscribers would. Runs AFTER validation and BEFORE the
222
+ # notification fans out — a falsy return means no subscriber ever
223
+ # sees the event. Unset gate (`nil`) always allows.
224
+ #
225
+ # @param payload [EventPayload]
226
+ # @return [Boolean]
227
+ def track_gate_allows?(payload)
228
+ gate = TrackRelay.config.track_gate
229
+ return true if gate.nil?
230
+
231
+ !!gate.call(payload: payload, request: Current.request)
232
+ end
233
+
218
234
  # Run {EventPayload#validate!} and apply the
219
235
  # {Configuration#raise_on_validation_error} gate.
220
236
  #
@@ -109,6 +109,9 @@ module TrackRelay
109
109
  def handle(payload)
110
110
  return nil if filtered?(payload.name.to_sym)
111
111
 
112
+ payload = prepare(payload)
113
+ return nil if payload.nil?
114
+
112
115
  if self.class.synchronous || TrackRelay.config.force_synchronous
113
116
  safe_deliver(payload)
114
117
  else
@@ -117,6 +120,23 @@ module TrackRelay
117
120
  end
118
121
  end
119
122
 
123
+ # Notification-time hook, called by {#handle} after the event-name
124
+ # filter and BEFORE the sync/async branch — i.e. while the request
125
+ # ({Current.request}) is still in scope, which is gone by the time
126
+ # an async {DeliveryJob} runs.
127
+ #
128
+ # Subclasses override this to gate delivery (return `nil` to drop
129
+ # the event silently — no job, no `deliver`) or to swap in an
130
+ # enriched **copy** of the payload. The same payload object fans
131
+ # out to every subscriber, so implementations must never mutate
132
+ # `payload` — build a new {EventPayload} instead.
133
+ #
134
+ # @param payload [EventPayload]
135
+ # @return [EventPayload, nil] the payload to deliver, or `nil` to drop
136
+ def prepare(payload)
137
+ payload
138
+ end
139
+
120
140
  # Wrap {#deliver} with the per-subscriber rescue.
121
141
  #
122
142
  # Returns `nil` on success or the StandardError on failure. ALWAYS
@@ -35,6 +35,12 @@ module TrackRelay
35
35
  # - `config.ga4_api_secret` — per-stream MP secret
36
36
  # - `config.ga4_use_eu_endpoint` — when `true`, post to
37
37
  # `https://region1.google-analytics.com/mp/collect`
38
+ # - `config.ga4_require_browser_client_id` — when `true`, deliver
39
+ # only for requests carrying a genuine `_ga` cookie (see
40
+ # {#prepare})
41
+ # - `config.ga4_enrich_page_context` — when `true`, merge
42
+ # `page_location` / `page_referrer` / `session_id` /
43
+ # `engagement_time_msec` into delivered params (see {#prepare})
38
44
  #
39
45
  # When `ga4_measurement_id` or `ga4_api_secret` is `nil`, `#deliver`
40
46
  # emits a single `Rails.logger.warn` and returns without raising —
@@ -85,6 +91,46 @@ module TrackRelay
85
91
  # drops them.
86
92
  RESERVED_PARAM_PREFIXES = %w[firebase_ ga_ google_].freeze
87
93
 
94
+ # Nominal engagement time attached alongside `session_id` when
95
+ # page-context enrichment finds a gtag session cookie. GA4 needs
96
+ # a nonzero value for server events to count toward session
97
+ # activity; the number itself is nominal (Google's Measurement
98
+ # Protocol examples use 100).
99
+ ENGAGEMENT_TIME_MSEC = 100
100
+
101
+ # Browser-proof gate + page-context enrichment (issue #1), both
102
+ # opt-in via configuration. Runs at notification time —
103
+ # {Current.request} is gone by the time an async {DeliveryJob}
104
+ # performs, so gating and enrichment MUST happen here and ride
105
+ # through the serialized payload.
106
+ #
107
+ # - `config.ga4_require_browser_client_id`: deliver only when the
108
+ # current request carries a genuine `_ga` cookie; the
109
+ # cookie-derived client_id lands in the delivered context.
110
+ # - `config.ga4_enrich_page_context`: merge `page_location`,
111
+ # conditional `page_referrer`, and conditional `session_id` /
112
+ # `engagement_time_msec` into the delivered params.
113
+ #
114
+ # Either path swaps in a **copy** — the shared payload other
115
+ # subscribers receive during fan-out is never mutated.
116
+ #
117
+ # @param payload [EventPayload]
118
+ # @return [EventPayload, nil] `nil` drops the event (no job)
119
+ def prepare(payload)
120
+ config = TrackRelay.config
121
+ return payload unless config.ga4_require_browser_client_id || config.ga4_enrich_page_context
122
+
123
+ request = Current.request
124
+ client_id = ClientId::Ga.from_request(request)
125
+ return nil if config.ga4_require_browser_client_id && client_id.nil?
126
+ return payload if request.nil?
127
+
128
+ hash = payload.to_h
129
+ hash[:params] = hash[:params].merge(page_params(request)) if config.ga4_enrich_page_context
130
+ hash[:context] = hash[:context].merge(client_id: client_id) if client_id
131
+ EventPayload.from_h(hash)
132
+ end
133
+
88
134
  # POST `payload` to the GA4 Measurement Protocol endpoint.
89
135
  #
90
136
  # See class docs for the full configuration / error contract.
@@ -112,6 +158,37 @@ module TrackRelay
112
158
 
113
159
  private
114
160
 
161
+ # Page-context params captured from the live request (only when
162
+ # `config.ga4_enrich_page_context` is enabled).
163
+ def page_params(request)
164
+ params = {page_location: request.original_url}
165
+ params[:page_referrer] = request.referer if request.referer && !request.referer.empty?
166
+
167
+ if (session_id = ga_session_id(request))
168
+ params[:session_id] = session_id
169
+ params[:engagement_time_msec] = ENGAGEMENT_TIME_MSEC
170
+ end
171
+ params
172
+ end
173
+
174
+ # The gtag session cookie is `_ga_<stream>` (measurement id minus
175
+ # the "G-" prefix), shaped `GS1.1.<session_id>.<session_number>...`.
176
+ # The format is Google-controlled, so parse defensively — too few
177
+ # segments or a blank session segment yields nil and the delivery
178
+ # proceeds without session params.
179
+ def ga_session_id(request)
180
+ stream = TrackRelay.config.ga4_measurement_id.to_s.delete_prefix("G-")
181
+ return nil if stream.empty?
182
+
183
+ cookie = request.cookies["_ga_#{stream}"]
184
+ return nil if cookie.nil? || cookie.empty?
185
+
186
+ parts = cookie.split(".")
187
+ return nil if parts.size < 4 || parts[2].nil? || parts[2].empty?
188
+
189
+ parts[2]
190
+ end
191
+
115
192
  # Run call-time payload constraint checks (REQ-27 split). Returns
116
193
  # `true` when delivery should proceed, `false` when a constraint
117
194
  # was violated AND `raise_on_validation_error` is `false` (the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TrackRelay
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: track_relay
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - Darrin Demchuk
7
+ - dchuk
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z